diff --git a/src/Game.Client/Assets/Programs/Editor/Tests/MVC/Horror/HorrorEquipmentServiceTests.cs b/src/Game.Client/Assets/Programs/Editor/Tests/MVC/Horror/HorrorEquipmentServiceTests.cs index f31168e27..55900ec8a 100644 --- a/src/Game.Client/Assets/Programs/Editor/Tests/MVC/Horror/HorrorEquipmentServiceTests.cs +++ b/src/Game.Client/Assets/Programs/Editor/Tests/MVC/Horror/HorrorEquipmentServiceTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Threading.Tasks; using Cysharp.Threading.Tasks; using Game.Horror.Constants; @@ -6,9 +7,14 @@ using Game.Horror.Services.Interfaces; using Game.Shared.Enums; using Game.Shared.SaveData; +using Game.Shared.Scriptable.Database; +using Game.Shared.Scriptable.Database.Tables; using Game.Shared.Services; using NSubstitute; using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; namespace Game.Tests.MVC.Horror { @@ -22,6 +28,8 @@ public class HorrorEquipmentServiceTests private IHorrorInventoryService _mockInventory; private IHorrorSaveRepository _repository; private IHorrorEquipmentService _service; + private HorrorWeaponMasterTable _weaponTable; + private ScriptableDatabase _database; [SetUp] public void Setup() @@ -30,7 +38,14 @@ public void Setup() _mockDatabase = Substitute.For(); _mockInventory = Substitute.For(); _repository = new HorrorSaveRepository(_mockStorage, _mockDatabase); - _service = new HorrorEquipmentService(_repository, _mockInventory); + _service = new HorrorEquipmentService(_repository, _mockInventory, _mockDatabase); + } + + [TearDown] + public void TearDown() + { + if (_weaponTable != null) Object.DestroyImmediate(_weaponTable); + if (_database != null) Object.DestroyImmediate(_database); } private async Task LoadDefaultData() @@ -54,6 +69,7 @@ private int CountOf(ObjectCategory type, int id) public async Task TryEquip_WeaponPossessed_SucceedsAndMarksDirty() { await LoadDefaultData(); + SetupRealDatabase(5); _mockInventory.HasObject(ObjectCategory.Weapon, 5).Returns(true); var ok = _service.TryEquip(ObjectCategory.Weapon, 5); @@ -95,6 +111,7 @@ public async Task TryEquip_NotPossessed_ReturnsFalse() public async Task TryEquip_SameWeaponAgain_ReturnsTrueIdempotently() { await LoadDefaultData(); + SetupRealDatabase(5); _mockInventory.HasObject(ObjectCategory.Weapon, 5).Returns(true); _service.TryEquip(ObjectCategory.Weapon, 5); @@ -254,6 +271,7 @@ public void SlotMutators_WhenDataNull_DoNotThrowAndReturnFalse() public async Task TryEquip_DoesNotAffectSlots() { await LoadDefaultData(); + SetupRealDatabase(7); _service.TrySetSlot(0, ObjectCategory.Weapon, 5); _mockInventory.HasObject(ObjectCategory.Weapon, 7).Returns(true); @@ -268,6 +286,7 @@ public async Task TryEquip_DoesNotAffectSlots() public async Task Assign_DoesNotAffectEquipped() { await LoadDefaultData(); + SetupRealDatabase(5); _mockInventory.HasObject(ObjectCategory.Weapon, 5).Returns(true); _service.TryEquip(ObjectCategory.Weapon, 5); @@ -282,6 +301,7 @@ public async Task Assign_DoesNotAffectEquipped() public async Task Clear_DoesNotAffectEquipped() { await LoadDefaultData(); + SetupRealDatabase(5); _mockInventory.HasObject(ObjectCategory.Weapon, 5).Returns(true); _service.TrySetSlot(0, ObjectCategory.Weapon, 5); _service.TryEquip(ObjectCategory.Weapon, 5); @@ -355,5 +375,177 @@ public void MagazineMutators_WhenDataNull_DoNotThrow() Assert.That(_service.GetMagazineCount(5, 30), Is.EqualTo(30)); }); } + + // 装備中武器のマスター解決:解決前・未ロード・未装備は null。プレイ開始時の解決 + // (ResolveEquippedWeaponMaster)と装備切替(TryEquip)で確定し、解決できない記録は未装備へ戻す。 + + /// + /// 指定 Id の武器レコードを持つ実テーブル+DB を組み立てて mock に接続する。 + /// EditorImportRows は列名をメンバ名と完全一致でマッピングし、無い列は既定値のままとなるため Id 列のみ投入する。 + /// + private void SetupRealDatabase(params int[] weaponIds) + { + _weaponTable = ScriptableObject.CreateInstance(); + var rows = new List>(); + foreach (var id in weaponIds) + rows.Add(new[] { id.ToString() }); + _weaponTable.EditorImportRows(new[] { "Id" }, rows, mergeByPrimaryKey: false); + + _database = ScriptableObject.CreateInstance(); + var so = new SerializedObject(_database); + so.FindProperty("horrorWeaponMasterTable").objectReferenceValue = _weaponTable; + so.ApplyModifiedPropertiesWithoutUndo(); + _mockDatabase.Database.Returns(_database); + } + + [Test] + public void EquippedWeaponMaster_BeforeResolve_IsNull() + { + Assert.That(_service.EquippedWeaponMaster, Is.Null); + } + + [Test] + public void ResolveEquippedWeaponMaster_WhenDataNull_ResolvesNull() + { + // LoadAsync 未実行 = Data は null。DB へも触れない + Assert.DoesNotThrow(() => _service.ResolveEquippedWeaponMaster()); + Assert.That(_service.EquippedWeaponMaster, Is.Null); + } + + [Test] + public async Task ResolveEquippedWeaponMaster_Unequipped_ResolvesNull() + { + await LoadDefaultData(); + + _service.ResolveEquippedWeaponMaster(); + + Assert.That(_service.EquippedWeaponMaster, Is.Null); + Assert.That(_repository.IsDirty, Is.False); + } + + [Test] + public async Task ResolveEquippedWeaponMaster_RestoresFromSaveData() + { + await LoadDefaultData(); + SetupRealDatabase(5); + _mockInventory.HasObject(ObjectCategory.Weapon, 5).Returns(true); + _service.TryEquip(ObjectCategory.Weapon, 5); + + // 装備記録の残るセーブデータに対し、未解決のサービス(=セーブのロード直後)を作り直す + var service = new HorrorEquipmentService(_repository, _mockInventory, _mockDatabase); + Assert.That(service.EquippedWeaponMaster, Is.Null); + + service.ResolveEquippedWeaponMaster(); + + Assert.That(service.EquippedWeaponMaster, Is.Not.Null); + Assert.That(service.EquippedWeaponMaster.Id, Is.EqualTo(5)); + } + + [Test] + public async Task ResolveEquippedWeaponMaster_MasterNotFound_LogsErrorAndClearsEquipment() + { + await LoadDefaultData(); + SetupRealDatabase(7); // 記録された Id=5 はテーブル未登録 → 解決失敗(不変条件違反)経路 + _repository.Data.Equipment.ObjectCategory = ObjectCategory.Weapon; + _repository.Data.Equipment.Id = 5; + + LogAssert.Expect(LogType.Error, "装備中の武器マスターが見つかりません Id=5。未装備へ戻します"); + _service.ResolveEquippedWeaponMaster(); + + Assert.That(_service.EquippedWeaponMaster, Is.Null); + Assert.That(_service.TryGetEquipped(out _, out _), Is.False); // 記録も実体(未装備)へ合わせる + Assert.That(_repository.IsDirty, Is.True); + } + + [Test] + public async Task TryEquip_MasterNotFound_LogsErrorAndDoesNotEquip() + { + await LoadDefaultData(); + SetupRealDatabase(7); // 装備する Id=5 はテーブル未登録 → 装備を成立させない + _mockInventory.HasObject(ObjectCategory.Weapon, 5).Returns(true); + + LogAssert.Expect(LogType.Error, "装備中の武器マスターが見つかりません Id=5"); + + Assert.That(_service.TryEquip(ObjectCategory.Weapon, 5), Is.False); + Assert.That(_service.EquippedWeaponMaster, Is.Null); + Assert.That(_service.TryGetEquipped(out _, out _), Is.False); + Assert.That(_repository.IsDirty, Is.False); + } + + [Test] + public async Task EquippedWeaponMaster_Resolved_FollowsEquipChange() + { + await LoadDefaultData(); + SetupRealDatabase(5, 7); + _mockInventory.HasObject(ObjectCategory.Weapon, 5).Returns(true); + _mockInventory.HasObject(ObjectCategory.Weapon, 7).Returns(true); + + _service.TryEquip(ObjectCategory.Weapon, 5); + Assert.That(_service.EquippedWeaponMaster, Is.Not.Null); + Assert.That(_service.EquippedWeaponMaster.Id, Is.EqualTo(5)); + + _service.TryEquip(ObjectCategory.Weapon, 7); + Assert.That(_service.EquippedWeaponMaster, Is.Not.Null); + Assert.That(_service.EquippedWeaponMaster.Id, Is.EqualTo(7)); + } + + // 装備候補一覧(GetEquippableWeaponMasters):スロット0→3→装備中の順で武器のみを解決し、 + // 同一 Id は重複排除、マスター未解決のスロット登録は無音でスキップする。 + + [Test] + public async Task GetEquippableWeaponMasters_NoSlotsNoEquip_ReturnsEmpty() + { + await LoadDefaultData(); + + Assert.That(_service.GetEquippableWeaponMasters(), Is.Empty); + } + + [Test] + public async Task GetEquippableWeaponMasters_CollectsSlotWeapons_SkipsNonWeaponAndUnresolvable() + { + await LoadDefaultData(); + SetupRealDatabase(5, 7); + _service.TrySetSlot(0, ObjectCategory.Weapon, 5); + _service.TrySetSlot(1, ObjectCategory.Weapon, 7); + _service.TrySetSlot(2, ObjectCategory.Item, 3); + _service.TrySetSlot(3, ObjectCategory.Weapon, 9); // テーブル未登録 → 無音スキップ + + var masters = _service.GetEquippableWeaponMasters(); + + Assert.That(masters, Has.Count.EqualTo(2)); + Assert.That(masters[0].Id, Is.EqualTo(5)); + Assert.That(masters[1].Id, Is.EqualTo(7)); + } + + [Test] + public async Task GetEquippableWeaponMasters_DeduplicatesEquippedAlreadyInSlots() + { + await LoadDefaultData(); + SetupRealDatabase(5); + _service.TrySetSlot(0, ObjectCategory.Weapon, 5); + _mockInventory.HasObject(ObjectCategory.Weapon, 5).Returns(true); + _service.TryEquip(ObjectCategory.Weapon, 5); + + var masters = _service.GetEquippableWeaponMasters(); + + Assert.That(masters, Has.Count.EqualTo(1)); + Assert.That(masters[0].Id, Is.EqualTo(5)); + } + + [Test] + public async Task GetEquippableWeaponMasters_AppendsEquippedNotInSlots() + { + await LoadDefaultData(); + SetupRealDatabase(5, 7); + _service.TrySetSlot(0, ObjectCategory.Weapon, 5); + _mockInventory.HasObject(ObjectCategory.Weapon, 7).Returns(true); + _service.TryEquip(ObjectCategory.Weapon, 7); + + var masters = _service.GetEquippableWeaponMasters(); + + Assert.That(masters, Has.Count.EqualTo(2)); + Assert.That(masters[0].Id, Is.EqualTo(5)); + Assert.That(masters[1].Id, Is.EqualTo(7), "装備中は末尾に合流する"); + } } } diff --git a/src/Game.Client/Assets/Programs/Editor/Tests/MVC/Horror/HorrorPlayerServiceTests.cs b/src/Game.Client/Assets/Programs/Editor/Tests/MVC/Horror/HorrorPlayerServiceTests.cs index e5a9a4d23..0bc405afb 100644 --- a/src/Game.Client/Assets/Programs/Editor/Tests/MVC/Horror/HorrorPlayerServiceTests.cs +++ b/src/Game.Client/Assets/Programs/Editor/Tests/MVC/Horror/HorrorPlayerServiceTests.cs @@ -1,12 +1,19 @@ +using System.Collections.Generic; using System.Threading.Tasks; using Cysharp.Threading.Tasks; +using Game.Horror.Constants; using Game.Horror.SaveData; using Game.Horror.Services; using Game.Horror.Services.Interfaces; using Game.Shared.SaveData; +using Game.Shared.Scriptable.Database; +using Game.Shared.Scriptable.Database.Tables; using Game.Shared.Services; using NSubstitute; using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; namespace Game.Tests.MVC.Horror { @@ -19,6 +26,8 @@ public class HorrorPlayerServiceTests private IScriptableDatabaseService _mockDatabase; private IHorrorSaveRepository _repository; private IHorrorPlayerService _service; + private HorrorPlayerMasterTable _playerTable; + private ScriptableDatabase _database; [SetUp] public void Setup() @@ -26,7 +35,14 @@ public void Setup() _mockStorage = Substitute.For(); _mockDatabase = Substitute.For(); _repository = new HorrorSaveRepository(_mockStorage, _mockDatabase); - _service = new HorrorPlayerService(_repository); + _service = new HorrorPlayerService(_repository, _mockDatabase); + } + + [TearDown] + public void TearDown() + { + if (_playerTable != null) Object.DestroyImmediate(_playerTable); + if (_database != null) Object.DestroyImmediate(_database); } private async Task LoadDefaultData() @@ -37,54 +53,97 @@ private async Task LoadDefaultData() await _repository.LoadAsync(); } - [Test] - public async Task SetLastSavepoint_RecordsAndMarksDirty() + /// + /// 指定 (Id, MaxHealth) のプレイヤーレコードを持つ実テーブル+DB を組み立てて mock に接続する。 + /// EditorImportRows は列名をメンバ名と完全一致でマッピングし、無い列は既定値のままとなる。 + /// + private void SetupRealDatabase(params (int Id, int MaxHealth)[] records) { - await LoadDefaultData(); + _playerTable = ScriptableObject.CreateInstance(); + var rows = new List>(); + foreach (var record in records) + rows.Add(new[] { record.Id.ToString(), record.MaxHealth.ToString() }); + _playerTable.EditorImportRows(new[] { "Id", "MaxHealth" }, rows, mergeByPrimaryKey: false); + + _database = ScriptableObject.CreateInstance(); + var so = new SerializedObject(_database); + so.FindProperty("horrorPlayerMasterTable").objectReferenceValue = _playerTable; + so.ApplyModifiedPropertiesWithoutUndo(); + _mockDatabase.Database.Returns(_database); + } - _service.SetLastSavepoint(10); + /// 新規データを作り、操作対象のプレイヤー Id だけを差し替える(書き込み API は無いため直接設定する)。 + private void CreateDataWithPlayerId(int playerId) + { + _repository.CreateData(); + _repository.Data.Player.PlayerId = playerId; + } - Assert.That(_service.LastSavepointId, Is.EqualTo(10)); - Assert.That(_repository.IsDirty, Is.True); + // プレイヤーマスターの解決:プレイヤー生成に先立って確定させる。要求 Id が引けなければ既定 Id へフォールバックする。 + + [Test] + public void PlayerMaster_BeforeResolve_IsNull() + { + Assert.That(_service.PlayerMaster, Is.Null); } [Test] - public async Task SetLastSavepoint_SameId_DoesNotMarkDirty() + public async Task ResolvePlayerMaster_NewData_ResolvesDefaultMaster() { + SetupRealDatabase((1, 100)); await LoadDefaultData(); - _service.SetLastSavepoint(10); - await _repository.SaveBySlotAsync(0); - Assert.That(_repository.IsDirty, Is.False); - - _service.SetLastSavepoint(10); - Assert.That(_repository.IsDirty, Is.False); + Assert.That(_service.ResolvePlayerMaster(), Is.True); + Assert.That(_service.PlayerMaster.Id, Is.EqualTo(HorrorSaveConstants.DefaultPlayerId)); } [Test] - public async Task SetLastSavepoint_Zero_IgnoredAndNotDirty() + public void ResolvePlayerMaster_WhenDataNull_ResolvesDefaultMaster() { - await LoadDefaultData(); + SetupRealDatabase((1, 100)); - _service.SetLastSavepoint(0); + Assert.That(_service.ResolvePlayerMaster(), Is.True); + Assert.That(_service.PlayerMaster.Id, Is.EqualTo(HorrorSaveConstants.DefaultPlayerId)); + Assert.That(_repository.IsDirty, Is.False); // 書き戻す先が無いため Dirty にしない + } - Assert.That(_service.LastSavepointId, Is.EqualTo(0)); - Assert.That(_repository.IsDirty, Is.False); + [Test] + public void ResolvePlayerMaster_UsesSavedPlayerId() + { + SetupRealDatabase((1, 100), (7, 50)); + CreateDataWithPlayerId(7); + + Assert.That(_service.ResolvePlayerMaster(), Is.True); + Assert.That(_service.PlayerMaster.Id, Is.EqualTo(7)); } [Test] - public void SetLastSavepoint_WhenDataNull_DoesNotThrow() + public void ResolvePlayerMaster_MasterNotFound_FallsBackToDefaultAndAlignsSaveData() { - Assert.DoesNotThrow(() => _service.SetLastSavepoint(10)); + SetupRealDatabase((1, 100)); + CreateDataWithPlayerId(7); + + LogAssert.Expect(LogType.Warning, "プレイヤーマスターが見つかりません Id=7。既定 Id=1 で代替します"); + + Assert.That(_service.ResolvePlayerMaster(), Is.True); + Assert.That(_service.PlayerMaster.Id, Is.EqualTo(HorrorSaveConstants.DefaultPlayerId)); + Assert.That(_repository.Data.Player.PlayerId, Is.EqualTo(HorrorSaveConstants.DefaultPlayerId)); // 記録も実体へ合わせる + Assert.That(_repository.IsDirty, Is.True); } [Test] - public void LastSavepointId_WhenDataNull_ReturnsZero() + public void ResolvePlayerMaster_DefaultAlsoMissing_LogsErrorAndReturnsFalse() { - Assert.That(_service.LastSavepointId, Is.EqualTo(0)); + SetupRealDatabase((7, 50)); + CreateDataWithPlayerId(9); + + LogAssert.Expect(LogType.Error, "プレイヤーマスターが見つかりません Id=9(既定 Id=1 も未登録)"); + + Assert.That(_service.ResolvePlayerMaster(), Is.False); + Assert.That(_service.PlayerMaster, Is.Null); } - // 残 HP の永続化:記録+Dirty 化、同値は Dirty にしない、未ロードは安全に no-op。 + // 残 HP の永続化:記録+Dirty 化、同値は Dirty にしない、未ロードは LogError の上で no-op。 // 新規データの既定 0 は「未記録」を意味し、復元側(NormalizeLoadedHealth)が Max へ正規化する前提。 [Test] @@ -112,8 +171,10 @@ public async Task SetCurrentHealth_SameValue_DoesNotMarkDirty() } [Test] - public void SetCurrentHealth_WhenDataNull_DoesNotThrow() + public void SetCurrentHealth_WhenDataNull_LogsErrorAndDoesNotThrow() { + LogAssert.Expect(LogType.Error, "セーブデータ未ロードのため SetCurrentHealth(40) を無視しました"); + Assert.DoesNotThrow(() => _service.SetCurrentHealth(40)); } @@ -131,37 +192,35 @@ public async Task CurrentHealth_NewData_IsZero() Assert.That(_service.CurrentHealth, Is.EqualTo(0)); } - // 最大 HP:マスタ由来のランタイム値。セーブリポジトリ非経由のためロード不要・Dirty 化しない。 + // 最大 HP:解決済みマスタ由来のランタイム値。セーブリポジトリ非経由のため Dirty 化しない。 [Test] - public void MaxHealth_Initial_IsZero() + public async Task MaxHealth_FromResolvedMaster() { - Assert.That(_service.MaxHealth, Is.EqualTo(0)); - } - - [Test] - public void SetMaxHealth_RecordsValue() - { - _service.SetMaxHealth(100); + SetupRealDatabase((1, 100)); + await LoadDefaultData(); + _service.ResolvePlayerMaster(); Assert.That(_service.MaxHealth, Is.EqualTo(100)); + Assert.That(_repository.IsDirty, Is.False); } [Test] - public void SetMaxHealth_DoesNotMarkDirty() + public async Task MaxHealth_BeforeResolve_IsZero() { - _service.SetMaxHealth(100); + await LoadDefaultData(); - Assert.That(_repository.IsDirty, Is.False); + Assert.That(_service.MaxHealth, Is.EqualTo(0)); } - // 満タン判定:MaxHealth 未設定(0)は満タン扱いにしない(誤って使用不能にならない)。 + // 満タン判定:MaxHealth 未解決(0)は満タン扱いにしない(誤って使用不能にならない)。 [Test] public async Task IsHealthFull_AtMax_IsTrue() { + SetupRealDatabase((1, 100)); await LoadDefaultData(); - _service.SetMaxHealth(100); + _service.ResolvePlayerMaster(); _service.SetCurrentHealth(100); Assert.That(_service.IsHealthFull, Is.True); @@ -170,8 +229,9 @@ public async Task IsHealthFull_AtMax_IsTrue() [Test] public async Task IsHealthFull_BelowMax_IsFalse() { + SetupRealDatabase((1, 100)); await LoadDefaultData(); - _service.SetMaxHealth(100); + _service.ResolvePlayerMaster(); _service.SetCurrentHealth(99); Assert.That(_service.IsHealthFull, Is.False); @@ -180,15 +240,16 @@ public async Task IsHealthFull_BelowMax_IsFalse() [Test] public async Task IsHealthFull_OverMax_IsTrue() { + SetupRealDatabase((1, 100)); await LoadDefaultData(); - _service.SetMaxHealth(100); + _service.ResolvePlayerMaster(); _service.SetCurrentHealth(150); Assert.That(_service.IsHealthFull, Is.True); } [Test] - public async Task IsHealthFull_MaxNotSet_IsFalse() + public async Task IsHealthFull_BeforeResolve_IsFalse() { await LoadDefaultData(); _service.SetCurrentHealth(40); diff --git a/src/Game.Client/Assets/Programs/Editor/Tests/MVC/Horror/HorrorSaveRepositoryTests.cs b/src/Game.Client/Assets/Programs/Editor/Tests/MVC/Horror/HorrorSaveRepositoryTests.cs index 06f18daa3..71056543e 100644 --- a/src/Game.Client/Assets/Programs/Editor/Tests/MVC/Horror/HorrorSaveRepositoryTests.cs +++ b/src/Game.Client/Assets/Programs/Editor/Tests/MVC/Horror/HorrorSaveRepositoryTests.cs @@ -50,7 +50,8 @@ public async Task Load_WhenNoFile_CreatesNewDataWithEmptySectionsAndFourEquipmen Assert.That(_repository.Data, Is.Not.Null); Assert.That(_repository.Data.Version, Is.EqualTo(HorrorSaveConstants.SaveDataLatestVersion)); - Assert.That(_repository.Data.Player.LastSavepointId, Is.EqualTo(0)); + Assert.That(_repository.Data.SavepointId, Is.EqualTo(0)); + Assert.That(_repository.Data.Player.PlayerId, Is.EqualTo(HorrorSaveConstants.DefaultPlayerId)); Assert.That(_repository.Data.Inventory.Slots, Is.Empty); Assert.That(_repository.Data.Interaction.InteractionIds, Is.Empty); Assert.That(_repository.Data.Equipment.ObjectCategory, Is.EqualTo(ObjectCategory.None)); @@ -81,12 +82,12 @@ public async Task Load_WhenSlotsNull_NormalizesToFourSlots() [Test] public async Task Load_ExistingDataWithZeroId_DoesNotTouchDatabase() { - // Id=0 は NormalizePlayer の != 0 ガードでマスター照会をスキップする。 - // Repository は区画一括正規化のため database 参照自体は取得するが、Player 区画のテーブルへは触れない。 + // Id=0 は NormalizeSavepoint の != 0 ガードでマスター照会をスキップする。 + // Repository は区画一括正規化のため database 参照自体は取得するが、セーブポイントのテーブルへは触れない。 var data = new HorrorSaveData { Version = 1, - Player = new HorrorPlayerSaveData { LastSavepointId = 0 }, + SavepointId = 0, }; _mockStorage.LoadAsync(SaveKey) .Returns(UniTask.FromResult(data)); @@ -94,7 +95,7 @@ public async Task Load_ExistingDataWithZeroId_DoesNotTouchDatabase() await _repository.LoadAsync(); Assert.That(_repository.Data, Is.Not.Null); - Assert.That(_repository.Data.Player.LastSavepointId, Is.EqualTo(0)); + Assert.That(_repository.Data.SavepointId, Is.EqualTo(0)); } [Test] @@ -103,7 +104,7 @@ public void Serialization_RoundTrip_PreservesAllSections() var original = new HorrorSaveData { Version = 1, - Player = new HorrorPlayerSaveData { LastSavepointId = 42 }, + Player = new HorrorPlayerSaveData { PlayerId = 7, CurrentHealth = 42 }, Inventory = new HorrorInventorySaveData { Slots = new List @@ -145,7 +146,8 @@ public void Serialization_RoundTrip_PreservesAllSections() Assert.That(restored, Is.Not.Null); Assert.That(restored.Version, Is.EqualTo(1)); - Assert.That(restored.Player.LastSavepointId, Is.EqualTo(42)); + Assert.That(restored.Player.PlayerId, Is.EqualTo(7)); + Assert.That(restored.Player.CurrentHealth, Is.EqualTo(42)); Assert.That(restored.Inventory.Slots.Count, Is.EqualTo(1)); Assert.That(restored.Inventory.Slots[0].ObjectCategory, Is.EqualTo(ObjectCategory.Item)); Assert.That(restored.Inventory.Slots[0].Id, Is.EqualTo(3)); @@ -189,18 +191,17 @@ public void Serialization_RoundTrip_PreservesMetaFields() } [Test] - public async Task SaveToSlotAsync_WithValidSlot_SavesToSlotKeyAndWritesMeta() + public async Task SaveToSlotAsync_WithValidSlot_SavesToSlotKeyAndStampsSlotMeta() { await LoadDefaultData(); _mockStorage.SaveAsync("horror_save_slot3", Arg.Any()) .Returns(UniTask.CompletedTask); - _repository.Data.Player.LastSavepointId = 42; + _repository.SetSavepointId(42); await _repository.SaveBySlotAsync(3); Assert.That(_repository.CurrentSlot, Is.EqualTo(3)); Assert.That(_repository.Data.SlotNo, Is.EqualTo(3)); - Assert.That(_repository.Data.SavepointId, Is.EqualTo(42)); Assert.That(_repository.Data.SavedAtUtc, Is.Not.EqualTo(default(DateTime))); await _mockStorage.Received(1).SaveAsync( "horror_save_slot3", @@ -264,5 +265,56 @@ public async Task LoadSlotInfosAsync_DoesNotMutateCurrentData() Assert.That(_repository.Data, Is.SameAs(currentData)); } + + // セーブポイント記録:記録+Dirty 化、Id 0・同値は Dirty にしない、未ロードは LogError の上で no-op。 + + [Test] + public async Task SetSavepointId_RecordsAndMarksDirty() + { + await LoadDefaultData(); + + _repository.SetSavepointId(10); + + Assert.That(_repository.Data.SavepointId, Is.EqualTo(10)); + Assert.That(_repository.IsDirty, Is.True); + } + + [Test] + public async Task SetSavepointId_SameId_DoesNotMarkDirty() + { + await LoadDefaultData(); + _repository.SetSavepointId(10); + await _repository.SaveBySlotAsync(0); + Assert.That(_repository.IsDirty, Is.False); + + _repository.SetSavepointId(10); + + Assert.That(_repository.IsDirty, Is.False); + } + + [Test] + public async Task SetSavepointId_Zero_IgnoredAndNotDirty() + { + await LoadDefaultData(); + + _repository.SetSavepointId(0); + + Assert.That(_repository.Data.SavepointId, Is.EqualTo(0)); + Assert.That(_repository.IsDirty, Is.False); + } + + [Test] + public void SetSavepointId_WhenDataNull_LogsErrorAndDoesNotThrow() + { + LogAssert.Expect(LogType.Error, "[HorrorSaveRepository] セーブデータ未ロードのため SetSavepointId(10) を無視しました"); + + Assert.DoesNotThrow(() => _repository.SetSavepointId(10)); + } + + [Test] + public void SavepointId_WhenDataNull_ReturnsZero() + { + Assert.That(_repository.Data?.SavepointId ?? 0, Is.EqualTo(0)); + } } } diff --git a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Constants/HorrorSaveConstants.cs b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Constants/HorrorSaveConstants.cs index 39701ebec..64944bd4f 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Constants/HorrorSaveConstants.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Constants/HorrorSaveConstants.cs @@ -7,5 +7,8 @@ public static class HorrorSaveConstants /// 現行のセーブデータバージョン。スキーマ変更時にここだけを上げる。 public const int SaveDataLatestVersion = 1; + + /// 既定のプレイヤー Id。新規セーブの初期値であり、マスター解決に失敗した Id のフォールバック先でもある。 + public const int DefaultPlayerId = 1; } } diff --git a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/HorrorGameLauncher.cs b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/HorrorGameLauncher.cs index d3b4a5a13..45df6a2d4 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/HorrorGameLauncher.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/HorrorGameLauncher.cs @@ -92,9 +92,9 @@ public async UniTask StartupAsync() // インベントリ → 装備(所持判定を注入)→ インタラクション → プレイヤーの順に生成し、I/F キーで共有登録 var inventoryService = new HorrorInventoryService(saveRepository); GameServiceManager.Register(inventoryService); - GameServiceManager.Register(new HorrorEquipmentService(saveRepository, inventoryService)); + GameServiceManager.Register(new HorrorEquipmentService(saveRepository, inventoryService, dbService)); GameServiceManager.Register(new HorrorInteractionService(saveRepository)); - GameServiceManager.Register(new HorrorPlayerService(saveRepository)); + GameServiceManager.Register(new HorrorPlayerService(saveRepository, dbService)); GameServiceManager.Register(new HorrorKeyItemService(saveRepository)); // 共通オブジェクト読み込み diff --git a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Interaction/HorrorSavepointInteractable.cs b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Interaction/HorrorSavepointInteractable.cs index f681b2ece..e3baba096 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Interaction/HorrorSavepointInteractable.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Interaction/HorrorSavepointInteractable.cs @@ -18,7 +18,6 @@ public class HorrorSavepointInteractable : InteractableBase [SerializeField] private Transform _respawnPoint; private IHorrorSaveRepository _saveRepository; - private IHorrorPlayerService _playerService; /// /// 復帰時のプレイヤー開始 Transform。未設定はシーン配線漏れとして LogError で顕在化し null を返す @@ -37,7 +36,6 @@ public Transform RespawnPoint protected override void Start() { _saveRepository = GameServiceManager.Resolve(); - _playerService = GameServiceManager.Resolve(); base.Start(); } @@ -55,7 +53,7 @@ private async UniTask InteractAsync() // 自身のインタラクト記録と復帰地点を選択後に Dirty 化し、今回の保存に含める base.Interact(); - _playerService.SetLastSavepoint(InteractionId); + _saveRepository.SetSavepointId(InteractionId); await _saveRepository.SaveBySlotAsync(selected); } } diff --git a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Player/HorrorPlayerController.States.cs b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Player/HorrorPlayerController.States.cs new file mode 100644 index 000000000..e0eb25ec9 --- /dev/null +++ b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Player/HorrorPlayerController.States.cs @@ -0,0 +1,555 @@ +using Cysharp.Threading.Tasks; +using Game.Horror.Interaction; +using Game.Library.Shared; +using Game.Shared.Enums; +using UnityEngine; + +namespace Game.Horror.Player +{ + public partial class HorrorPlayerController + { + #region StateMachine + + private void InitializeStateMachine() + { + _stateMachine = new StateMachine(this); + + // 状態遷移テーブルの構築 + _stateMachine.AddTransition(StateEvent.Move); + _stateMachine.AddTransition(StateEvent.Stop); + + _stateMachine.AddTransition(StateEvent.Jump); + _stateMachine.AddTransition(StateEvent.Jump); + + _stateMachine.AddTransition(StateEvent.Land); + + _stateMachine.AddTransition(StateEvent.Interact); + _stateMachine.AddTransition(StateEvent.Interact); + _stateMachine.AddTransition(StateEvent.EndInteract); + + _stateMachine.AddTransition(StateEvent.Attack); + _stateMachine.AddTransition(StateEvent.Attack); + _stateMachine.AddTransition(StateEvent.EndAttack); + + _stateMachine.AddTransition(StateEvent.Equip); + _stateMachine.AddTransition(StateEvent.Equip); + _stateMachine.AddTransition(StateEvent.EndEquip); + + _stateMachine.AddTransition(StateEvent.Reload); + _stateMachine.AddTransition(StateEvent.Reload); + _stateMachine.AddTransition(StateEvent.EndReload); + + _stateMachine.AddTransition(StateEvent.UseItem); + _stateMachine.AddTransition(StateEvent.UseItem); + _stateMachine.AddTransition(StateEvent.EndUseItem); + + _stateMachine.AddTransition(StateEvent.Idle); + _stateMachine.AddTransition(StateEvent.Dead); + + // 初期ステート + _stateMachine.SetInitState(); + } + + /// + /// 状態遷移イベントKey + /// + private enum StateEvent + { + Idle, // 待機状態: Idle + Move, // 移動開始: Idle → Moving + Stop, // 移動停止: Moving → Idle + Jump, // ジャンプ: Idle/Moving → Jumping + Land, // 着地: Jumping → Idle + Interact, // インタラクト開始: Idle/Moving → Interacting + EndInteract, // インタラクト終了: Interacting → Idle + Attack, // 攻撃開始: Idle/Moving → Attacking + EndAttack, // 攻撃終了(発射間隔経過): Attacking → Idle + Equip, // 装備切替開始: Idle/Moving → Equipping + EndEquip, // 装備切替終了(EquipDuration経過): Equipping → Idle + Reload, // リロード開始: Idle/Moving → Reloading + EndReload, // リロード終了(ReloadDuration経過): Reloading → Idle + UseItem, // アイテム使用開始: Idle/Moving → UsingItem + EndUseItem, // アイテム使用終了(EffectApplyDuration経過): UsingItem → Idle + Dead, + } + + private class IdleState : State + { + public override void Update() + { + var ctx = Context; + ctx.UpdateRotation(); + ctx.UpdateCrouchPose(); + ctx.UpdateHeadBob(); + ctx.UpdateAimPose(); + + // ジャンプ入力チェック + if (ctx._jumpTriggered && ctx.IsGrounded()) + { + StateMachine.Transition(StateEvent.Jump); + return; + } + + // インタラクト起動チェック + if (ctx.TryInteraction()) + { + StateMachine.Transition(StateEvent.Interact); + return; + } + + // 攻撃(射撃)起動チェック + if (ctx.TryAttack()) + { + StateMachine.Transition(StateEvent.Attack); + return; + } + + // アイテム使用起動チェック(装備予約と併存した場合は回復を先に実行する) + if (ctx.TryUseItem()) + { + StateMachine.Transition(StateEvent.UseItem); + return; + } + + // 装備切替起動チェック + if (ctx.TryEquip()) + { + StateMachine.Transition(StateEvent.Equip); + return; + } + + // リロード起動チェック + if (ctx.TryReload()) + { + StateMachine.Transition(StateEvent.Reload); + return; + } + + // 移動入力チェック + if (ctx.IsMoveInput()) + { + StateMachine.Transition(StateEvent.Move); + } + } + + public override void FixedUpdate() + { + // 静止中も重力を適用 + Context.UpdateMovementWithGravity(Vector3.zero); + } + } + + private class MovingState : State + { + public override void Update() + { + var ctx = Context; + ctx.UpdateRotation(); + ctx.UpdateCrouchPose(); + ctx.UpdateHeadBob(); + ctx.UpdateAimPose(); + + // ジャンプ入力チェック + if (ctx._jumpTriggered && ctx.IsGrounded()) + { + StateMachine.Transition(StateEvent.Jump); + return; + } + + // インタラクト起動チェック + if (ctx.TryInteraction()) + { + StateMachine.Transition(StateEvent.Interact); + return; + } + + // 攻撃(射撃)起動チェック + if (ctx.TryAttack()) + { + StateMachine.Transition(StateEvent.Attack); + return; + } + + // アイテム使用起動チェック(装備予約と併存した場合は回復を先に実行する) + if (ctx.TryUseItem()) + { + StateMachine.Transition(StateEvent.UseItem); + return; + } + + // 装備切替起動チェック + if (ctx.TryEquip()) + { + StateMachine.Transition(StateEvent.Equip); + return; + } + + // リロード起動チェック + if (ctx.TryReload()) + { + StateMachine.Transition(StateEvent.Reload); + return; + } + + // 移動入力がなくなったらIdleへ + if (!ctx.IsMoveInput()) + { + StateMachine.Transition(StateEvent.Stop); + } + } + + public override void FixedUpdate() + { + var ctx = Context; + ctx.UpdateMovementWithGravity(ctx.ComputeHorizontalVelocity()); + } + } + + private class JumpingState : State + { + public override void Enter() + { + var ctx = Context; + ctx._verticalVelocity = ctx.PlayerMaster.Jump; + ctx._jumpTriggered = false; + } + + public override void Update() + { + var ctx = Context; + ctx.UpdateRotation(); + ctx.UpdateCrouchPose(); + ctx.UpdateHeadBob(); + ctx.UpdateAimPose(); + + // 上昇終了 + 接地で着地判定 + if (ctx._verticalVelocity <= 0f && ctx.IsGrounded()) + { + StateMachine.Transition(StateEvent.Land); + } + } + + public override void FixedUpdate() + { + var ctx = Context; + // 空中でも水平移動を許可 + ctx.UpdateMovementWithGravity(ctx.ComputeHorizontalVelocity()); + } + } + + /// + /// インタラクト実行中の身体占有状態。視点回転とエイム解除の補間のみ許可し水平移動は止める。 + /// 入力タイプを問わず、拒否メッセージ/単発・トグル/長押しを 1 本の非同期シーケンスで処理する。 + /// + private class InteractingState : State + { + private bool _completed; + + public override void Enter() + { + _completed = false; + RunAsync(Context._interactTarget).Forget(); + } + + public override void Update() + { + Context.UpdateRotation(); // 拘束中は視点回転とエイム解除の補間のみ許可 + Context.UpdateAimPose(); + if (_completed) StateMachine.Transition(StateEvent.EndInteract); + } + + // 水平移動なし=拘束(重力のみ適用) + public override void FixedUpdate() => Context.UpdateMovementWithGravity(Vector3.zero); + + public override void Exit() + { + var ctx = Context; + ctx._interactTarget?.SetHoldProgress(0f); // 中断・完了とも即非表示 + ctx._interactTarget = null; + _completed = false; + } + + // 1 回のインタラクトを開始~効果発火まで逐次処理する。 + // 拒否(メッセージ)/単発・トグル(即時)/長押し(進捗)を 1 本のフローで扱う。 + private async UniTask RunAsync(IInteractable target) + { + if (!target.CanInteract()) + { + await target.TryShowRejectionMessage(); + } + else if (target.InputType == InteractionInputType.Hold) + { + await RunHoldAsync(target); + } + else + { + target.Interact(); + } + + _completed = true; + } + + private async UniTask RunHoldAsync(IInteractable target) + { + var ctx = Context; + var elapsed = 0f; + target.SetHoldProgress(0f); + + while (true) + { + // 中断条件:対象喪失 / 視線を外した / ボタン解放 / 実行不可化 / 死亡 + var stillAimed = ctx._interactionDetector != null + && ctx._interactionDetector.TryGetTarget(out var current) + && current == target; + if (!stillAimed || !ctx.Player.Interact.IsPressed() || !target.CanInteract() || ctx.IsDead) + return; + + elapsed += Time.deltaTime; + target.SetHoldProgress(CalculateHoldProgress(elapsed, target.HoldSeconds)); + + if (elapsed >= target.HoldSeconds) + { + target.Interact(); + return; + } + + await UniTask.Yield(PlayerLoopTiming.Update); + } + } + } + + /// + /// 射撃実行中の状態。Enter で 1 発発砲し、FireInterval(武器マスター)の間は移動・視点を許可しつつ + /// 次弾の発射を待たせる(発射レート制限)。間隔を消化したら Idle へ戻る。 + /// + private class AttackingState : State + { + private float _elapsed; + + public override void Enter() + { + // インスタンスはキャッシュ再利用されるため経過時間を必ずリセット + _elapsed = 0f; + Context.Fire(); + } + + public override void Update() + { + var ctx = Context; + ctx.UpdateRotation(); + ctx.UpdateCrouchPose(); + ctx.UpdateHeadBob(); + ctx.UpdateAimPose(); + + _elapsed += Time.deltaTime; + if (_elapsed >= ctx.GetFireInterval()) + StateMachine.Transition(StateEvent.EndAttack); + } + + public override void FixedUpdate() + { + var ctx = Context; + ctx.UpdateMovementWithGravity(ctx.ComputeHorizontalVelocity()); + } + } + + /// + /// 装備切替実行中の状態。Enter で装備をセーブデータへ反映し、EquipDuration(武器マスター)の間は + /// 移動・視点を許可しつつ硬直として滞在する。滞在秒を消化したら Idle へ戻る。 + /// + private class EquippingState : State + { + private float _elapsed; + + public override void Enter() + { + // インスタンスはキャッシュ再利用されるため経過時間を必ずリセット + _elapsed = 0f; + + var ctx = Context; + if (ctx._equipmentService.TryEquip(ctx._pendingEquipType, ctx._pendingEquipId)) + { + ctx._weaponView.BeginSwitch(ctx._pendingWeaponMaster); + ctx._equipmentsView.Show(ctx._pendingEquipType, ctx._pendingEquipId); + Debug.Log($"{ctx._pendingWeaponMaster.Name}"); + } + else + { + // 直前フレームの TryPrepareEquip で検証済みのため通常プレイでは到達しない(到達=不変条件違反) + Debug.LogError($"装備反映に失敗しました ({ctx._pendingEquipType}, {ctx._pendingEquipId})"); + } + } + + public override void Update() + { + var ctx = Context; + ctx.UpdateRotation(); + ctx.UpdateCrouchPose(); + ctx.UpdateHeadBob(); + + _elapsed += Time.deltaTime; + ctx._weaponView.TickSwitch(_elapsed, ctx._pendingWeaponMaster.EquipDuration); + ctx.UpdateAimPose(); // TickSwitch の後に呼ぶ(下げ量更新 → 位置反映の順序) + if (_elapsed >= ctx._pendingWeaponMaster.EquipDuration) + StateMachine.Transition(StateEvent.EndEquip); + } + + public override void FixedUpdate() + { + var ctx = Context; + ctx.UpdateMovementWithGravity(ctx.ComputeHorizontalVelocity()); + } + } + + /// + /// リロード実行中の状態。ReloadDuration(武器マスター)の間、移動・視点・エイムを許可しつつ硬直として滞在し、 + /// 武器を傾ける演出を進める。滞在秒を消化した時点で装填(弾倉回復・予備消費)を適用して Idle へ戻る。 + /// 攻撃・ジャンプ・インタラクトの起動は入力側・遷移構造で禁止される。 + /// + private class ReloadingState : State + { + private float _elapsed; + private float _duration; + private bool _applied; + + public override void Enter() + { + var ctx = Context; + // インスタンスはキャッシュ再利用されるため経過時間・適用済みフラグを必ずリセット + _elapsed = 0f; + _applied = false; + _duration = ctx.EquippedWeaponMaster.ReloadDuration; + ctx.NotifyHudViews(); + } + + public override void Update() + { + var ctx = Context; + ctx.UpdateRotation(); + ctx.UpdateCrouchPose(); + ctx.UpdateHeadBob(); + + _elapsed += Time.deltaTime; + ctx._weaponView.TickReload(_elapsed, _duration); + ctx.UpdateAimPose(); // TickReload の後に呼ぶ(傾き量更新 → 反映の順序) + + if (!_applied && _elapsed >= _duration) + { + _applied = true; // フレーム落ち・将来の中断遷移追加に対する二重適用防止 + ctx.ApplyReload(); + StateMachine.Transition(StateEvent.EndReload); + } + } + + public override void FixedUpdate() + { + var ctx = Context; + ctx.UpdateMovementWithGravity(ctx.ComputeHorizontalVelocity()); + } + + public override void Exit() + { + // 中断・完了とも傾き演出を確実に解除する + Context._weaponView.ResetReload(); + } + } + + /// + /// アイテム使用(回復)実行中の状態。EffectApplyDuration(アイテムマスター)の間、移動・視点を許可しつつ + /// 硬直として滞在し、経過比率に応じた回復を毎フレーム差分適用する(漸進適用。完了時一括ではない)。 + /// アイテムは開始時点で消費するため、死亡による中断時は途中までの回復適用・消費済みのまま終わる。 + /// 攻撃・リロード・インタラクトの起動は入力側・遷移構造で禁止される。 + /// + private class UsingItemState : State + { + private float _elapsed; + private int _appliedHeal; + + public override void Enter() + { + var ctx = Context; + // インスタンスはキャッシュ再利用されるため経過時間・適用済み量を必ずリセット + _elapsed = 0f; + _appliedHeal = 0; + + // 開始時点で消費を確定する。TryUseItem の所持検証と同一フレームのため、失敗はデータ異常時のみの防御パス + if (!ctx._inventoryService.TryConsume(ObjectCategory.Item, ctx._pendingUseItemMaster.Id, 1)) + { + Debug.LogError($"アイテム消費に失敗したため使用を中止します Id={ctx._pendingUseItemMaster.Id}", ctx); + StateMachine.Transition(StateEvent.EndUseItem); + } + } + + public override void Update() + { + var ctx = Context; + ctx.UpdateRotation(); + ctx.UpdateCrouchPose(); + ctx.UpdateHeadBob(); + + _elapsed += Time.deltaTime; + ApplyProgressiveHeal(ctx); + ctx.UpdateAimPose(); + + if (_elapsed >= ctx._pendingUseItemMaster.EffectApplyDuration) + StateMachine.Transition(StateEvent.EndUseItem); + } + + public override void FixedUpdate() + { + var ctx = Context; + ctx.UpdateMovementWithGravity(ctx.ComputeHorizontalVelocity()); + } + + public override void Exit() + { + // 中断(死亡)時も残量の一括適用はしない(途中までの回復のみが残る) + Context._pendingUseItemMaster = null; + } + + // 経過比率から適用済み総量を再計算し、前フレームとの差分のみを加算する(丸め誤差の蓄積防止) + private void ApplyProgressiveHeal(HorrorPlayerController ctx) + { + var master = ctx._pendingUseItemMaster; + var applied = CalculateAppliedHeal(master.Effect, _elapsed, master.EffectApplyDuration); + var diff = applied - _appliedHeal; + if (diff <= 0) return; + + _appliedHeal = applied; + ctx.ApplyHealth(CalculateHealedHealth(ctx._playerService.CurrentHealth, diff, ctx._playerService.MaxHealth)); + } + } + + /// + /// 死亡状態(終端) + /// 復帰は GameOverDialog のシーン遷移=プレイヤー再生成で行われる)。 + /// 入力は UpdateInput の restrained(死亡込み)で遮断し、水平移動を止め重力のみ適用する。 + /// Enter で演出ディレイ → HorrorGameOverDialog.RunAsync のシーケンスを起動する。 + /// + private class DeadState : State + { + public override void Enter() + { + var ctx = Context; + + // 遷移フレームからエイム解除の補間を開始する(他の入力フラグは終端ステートでは読まれないため触らない。 + // 次フレーム以降は UpdateInput の restrained 分岐が毎フレーム解除を保証する) + ctx._isAiming = false; + + // ヘッドボブの残オフセットを rest 位置へ戻す(以降 UpdateHeadBob は呼ばない) + if (ctx._mainCamera != null) + ctx._mainCamera.transform.localPosition = ctx._cameraBasePosition; + + ctx.RunGameOverAsync().Forget(); + } + + // 視点回転なし。エイム解除補間・FOV 復帰・残弾 HUD フェードアウトのみ進める + public override void Update() => Context.UpdateAimPose(); + + // 水平移動なし=拘束(重力のみ適用)— InteractingState と同じ + public override void FixedUpdate() => Context.UpdateMovementWithGravity(Vector3.zero); + } + + #endregion + } +} diff --git a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Player/HorrorPlayerController.States.cs.meta b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Player/HorrorPlayerController.States.cs.meta new file mode 100644 index 000000000..97f1a8a7c --- /dev/null +++ b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Player/HorrorPlayerController.States.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0907ccc86409f6a4faa059ba974ce93b \ No newline at end of file diff --git a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Player/HorrorPlayerController.cs b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Player/HorrorPlayerController.cs index e66fd1f71..f840951ec 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Player/HorrorPlayerController.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Player/HorrorPlayerController.cs @@ -1,7 +1,5 @@ -using System.Collections.Generic; using Cysharp.Threading.Tasks; using Game.Core.Services; -using Game.Horror.Constants; using Game.Horror.Dialogs; using Game.Horror.Equipment; using Game.Horror.Interaction; @@ -26,40 +24,14 @@ namespace Game.Horror.Player /// Horror 用プレイヤーコントローラー(CharacterController ベース) /// [RequireComponent(typeof(CharacterController))] - public class HorrorPlayerController : MonoBehaviour, IDamageable + public partial class HorrorPlayerController : MonoBehaviour, IDamageable { - [SerializeField] private int _playerId = 1; - [SerializeField] private Camera _mainCamera; - [SerializeField] private float _walkSpeed = 2.0f; - [SerializeField] private float _runSpeed = 5.0f; - [SerializeField] private float _jump = 5.0f; - [SerializeField] private float _gravity = -20.0f; - [Header("しゃがみ")] - [SerializeField] private float _crouchSpeed = 1.2f; - [SerializeField] private float _crouchHeight = 1.0f; - - [Tooltip("立ち↔しゃがみ補間の応答速度(1-exp(-k・dt) の k)")] - [SerializeField] private float _crouchTransitionSpeed = 8f; - [Tooltip("立ち上がり判定の対象レイヤー。プレイヤー自身のレイヤーは含めないこと")] [SerializeField] private LayerMask _ceilingMask; - [Header("回転速度(度/秒)")] - [SerializeField] private float _lookRotationSpeed = 0.1f; - - [Header("エイム")] - [Tooltip("エイム構え補間の応答速度(1-exp(-k・dt) の k)")] - [SerializeField] private float _aimTransitionSpeed = 8f; - - [Tooltip("エイム中のカメラ回転速度倍率")] - [SerializeField] private float _aimRotationMultiplier = 0.4f; - - [Tooltip("エイム中にカメラ揺れをゼロへ減衰させる秒数(解除時の復帰も同じ秒数)")] - [SerializeField] private float _aimShakeFadeSeconds = 1f; - [Header("インタラクション")] [Tooltip("インタラクト対象を検出する検出器(同一 Prefab 上にアタッチ)")] [SerializeField] private InteractionDetector _interactionDetector; @@ -98,6 +70,12 @@ public class HorrorPlayerController : MonoBehaviour, IDamageable private IHorrorInventoryService _inventoryService; private IHorrorPlayerService _playerService; + /// 操作中プレイヤーの解決済みマスター(真実源=プレイヤーサービス。null = 解決失敗)。 + private HorrorPlayerMaster PlayerMaster => _playerService.PlayerMaster; + + /// 装備中武器の解決済みマスター(真実源=装備サービス。null = 未装備)。 + private HorrorWeaponMaster EquippedWeaponMaster => _equipmentService.EquippedWeaponMaster; + private CharacterController _characterController; // ステートマシーン @@ -113,8 +91,7 @@ public class HorrorPlayerController : MonoBehaviour, IDamageable private bool _interactTriggered; private IInteractable _interactTarget; - // 攻撃(ハンドガン):マスター値・銃声発行サービス・起動入力フラグ(硬直経過は AttackingState ローカル) - private HorrorWeaponMaster _weaponMaster; + // 攻撃(ハンドガン):起動入力フラグ(硬直経過は AttackingState ローカル) private bool _attackTriggered; // 発砲カメラリコイル(減衰オフセット型)。強度・回復秒は発砲時点のマスター値をキャプチャし、表示 pitch にのみ合成する(照準の真値 _cameraVerticalAngle は変えない) @@ -185,40 +162,14 @@ public class HorrorPlayerController : MonoBehaviour, IDamageable // カメラ揺れ設定(ヘッドボブ figure-8 + ストライド同期ロール、停止時はアイドルスウェイ) private Vector3 _cameraBasePosition; private Vector3 _standCameraBasePosition; // 立ち目線の不変参照点(しゃがみ補間の基準) - private float _bobPhase; + private float _headBobPhase; private float _idlePhase; // アイドルスウェイの常時位相 - private float _moveBobWeight; // 0=停止, 1=移動(ease)。cameraShake とは分離 + private float _moveHeadBobWeight; // 0=停止, 1=移動(ease)。cameraShake とは分離 private float _cameraShake = 1f; - // ヘッドボブ/アイドルスウェイ調整値(HorrorPlayerMaster から Initialize で上書き。初期値はマスター欠落時のフォールバック) - private float _bobWalkAmplitude = 0.04f; // 歩き:縦位置振幅(m) - private float _bobRunAmplitude = 0.06f; // 走り:縦位置振幅(m) - private float _bobWalkSpeed = 10f; // 歩き:位相速度 rad/s(ゆっくり) - private float _bobRunSpeed = 15f; // 走り:位相速度 rad/s(少しだけ速い) - private float _bobHorizontalRatio = 0.5f; // 横位置/縦位置 比 - private float _bobWalkRoll = 0.05f; // 歩き:ロール角(度)=知覚される横揺れ - private float _bobRunRoll = 0.1f; // 走り:ロール角(度) - private float _bobAmplitudeResponse = 10f; // 強度イーズの応答 - - private float _idleSwaySpeed = 1.2f; // アイドル:位相速度 rad/s(呼吸 ~5秒周期) - private float _idleSwayAmplitude = 0.05f; // アイドル:縦位置振幅(m, ヘッドボブより小) - private float _idleSwayRoll = 0.01f; // アイドル:ロール角(度, 小) - - // 足音調整値(HorrorPlayerMaster から Initialize で上書き。初期値はマスター欠落時のフォールバック) - private float _footstepStride = 1.25f; // 1歩とみなす移動距離(m) - private float _footstepWalkLoudness = 0.5f; // 歩き足音の Loudness(HearingRadius 30 × 0.5 = 15m 到達) - private float _footstepRunLoudness = 1f; // 走り足音の Loudness(30m 到達) - private string _footstepSeAssetName = ""; // 足音 SE アセット名(空=再生しない) - // 足音の歩幅積算(m)。しゃがみ・非接地・入力ブロック中はリセット private float _footstepAccumulatedDistance; - // 体力・無敵時間(HorrorPlayerMaster から Initialize で上書き。初期値はマスター欠落時のフォールバック) - private int _maxHealth = 100; // 最大 HP - private float _invincibleSeconds = 0.8f; // 被弾後の無敵時間(秒) - private string _damageSeAssetName = ""; // 被弾 SE アセット名(空=再生しない) - - private int _currentHealth; // 残 HP(Initialize でセーブデータから復元) private float _lastDamageTime = float.NegativeInfinity; // 最終被弾時刻(Time.time)。負の無限大=未被弾 // 死亡から GameOverDialog 表示までの演出ディレイ(ms)。被弾フラッシュ・SE を見せてから遷移する @@ -226,6 +177,9 @@ public class HorrorPlayerController : MonoBehaviour, IDamageable public void Initialize(HorrorOptionSaveData data) { + _playerService = GameServiceManager.Resolve(); + if (PlayerMaster == null) return; + _inputService = GameServiceManager.Resolve(); _inputService.EnablePlayer(forceEnable: true); @@ -236,31 +190,23 @@ public void Initialize(HorrorOptionSaveData data) _dbService = GameServiceManager.Resolve(); _equipmentService = GameServiceManager.Resolve(); _inventoryService = GameServiceManager.Resolve(); - _playerService = GameServiceManager.Resolve(); - - ApplyPlayerMaster(); - - // マスタ適用後の実効最大 HP を共有する(インベントリの満タン判定等が参照する) - _playerService.SetMaxHealth(_maxHealth); // 残 HP をセーブデータから復元(0 以下=旧セーブ・新規データは Max へ正規化し、結果を書き戻す) - ApplyHealth(NormalizeLoadedHealth(_playerService.CurrentHealth, _maxHealth)); + ApplyHealth(NormalizeLoadedHealth(_playerService.CurrentHealth, _playerService.MaxHealth)); - _equipmentsView.Initialize(); + // 武器モデルの復元に先立ち、セーブの装備記録からマスターを確定する + _equipmentService.ResolveEquippedWeaponMaster(); - // 装備状態をセーブデータから復元。未装備なら _weaponMaster は null のまま(TryAttack の既存 null ガードで攻撃不可) - if (_equipmentService.TryGetEquipped(out var slotType, out var id) - && slotType == ObjectCategory.Weapon - && _dbService.Database.HorrorWeaponMasterTable.TryFindById(id, out var weaponMaster)) - { - _weaponMaster = weaponMaster; - } + // 装備ショートカットビュー初期化 + _equipmentsView.Initialize(); // 武器モデル表示ビューの初期化:装備中なら即座に表示し、ショートカット登録武器のモデルは事前ロードしておく + // (マスターは上で確定済み。未装備なら null=TryAttack の null ガードで攻撃不可) _weaponView.Initialize(); - if (_weaponMaster != null) _weaponView.ShowImmediate(_weaponMaster); + var equippedWeapon = EquippedWeaponMaster; + if (equippedWeapon != null) _weaponView.ShowImmediate(equippedWeapon); - _weaponView.PreloadAsync(ResolveEquippableMasters()).Forget(); + _weaponView.PreloadAsync(_equipmentService.GetEquippableWeaponMasters()).Forget(); TryGetComponent(out _characterController); @@ -285,47 +231,6 @@ public void Initialize(HorrorOptionSaveData data) _initialized = true; } - private void ApplyPlayerMaster() - { - // プレイヤー調整値をマスターデータで上書き(SerializeField/既定値はマスター欠落時のフォールバック) - if (_dbService.Database.HorrorPlayerMasterTable.TryFindById(_playerId, out var playerMaster)) - { - _walkSpeed = playerMaster.WalkSpeed; - _runSpeed = playerMaster.RunSpeed; - _jump = playerMaster.Jump; - _gravity = playerMaster.Gravity; - _crouchSpeed = playerMaster.CrouchSpeed; - _crouchHeight = playerMaster.CrouchHeight; - _crouchTransitionSpeed = playerMaster.CrouchTransitionSpeed; - _lookRotationSpeed = playerMaster.LookRotationSpeed; - _aimTransitionSpeed = playerMaster.AimTransitionSpeed; - _aimRotationMultiplier = playerMaster.AimRotationMultiplier; - _aimShakeFadeSeconds = playerMaster.AimShakeFadeSeconds; - _bobWalkAmplitude = playerMaster.BobWalkAmplitude; - _bobRunAmplitude = playerMaster.BobRunAmplitude; - _bobWalkSpeed = playerMaster.BobWalkSpeed; - _bobRunSpeed = playerMaster.BobRunSpeed; - _bobHorizontalRatio = playerMaster.BobHorizontalRatio; - _bobWalkRoll = playerMaster.BobWalkRoll; - _bobRunRoll = playerMaster.BobRunRoll; - _bobAmplitudeResponse = playerMaster.BobAmplitudeResponse; - _idleSwaySpeed = playerMaster.IdleSwaySpeed; - _idleSwayAmplitude = playerMaster.IdleSwayAmplitude; - _idleSwayRoll = playerMaster.IdleSwayRoll; - _footstepStride = playerMaster.FootstepStride; - _footstepWalkLoudness = playerMaster.FootstepWalkLoudness; - _footstepRunLoudness = playerMaster.FootstepRunLoudness; - _footstepSeAssetName = playerMaster.FootstepSeAssetName; - _maxHealth = playerMaster.MaxHealth; - _invincibleSeconds = playerMaster.InvincibleSeconds; - _damageSeAssetName = playerMaster.DamageSeAssetName; - } - else - { - Debug.LogError($"HorrorPlayerMaster が見つかりません Id={_playerId}。Inspector/既定値で継続します。", this); - } - } - public void ApplyOptions(HorrorOptionSaveData data) { _lookInvertX = data.CameraControlHorizontal ? -1f : 1f; @@ -389,18 +294,17 @@ public void RequestUseItem(ObjectCategory category, int id) #region IDamageable /// 死亡フラグ(体力が 0 以下) - public bool IsDead => _currentHealth <= 0; + public bool IsDead => _playerService.CurrentHealth <= 0; /// - /// 残 HP を更新し、セーブデータ同期と HUD 値反映を一括で行う(HP 書き込みの単一点)。 + /// 残 HP をサービス(セーブデータ)へ書き込み、HUD 値反映を一括で行う(HP 書き込みの単一点)。 /// 表示キック(Notify)は行わない(必要な呼び出し側のみが行う)。 /// private void ApplyHealth(int newHealth) { - _currentHealth = newHealth; _playerService.SetCurrentHealth(newHealth); if (_healthView != null) - _healthView.UpdateHealth(newHealth, _maxHealth); + _healthView.UpdateHealth(newHealth, _playerService.MaxHealth); } /// @@ -411,17 +315,17 @@ private void ApplyHealth(int newHealth) public void TakeDamage(int damage) { if (IsDead) return; - if (IsInvincible(Time.time, _lastDamageTime, _invincibleSeconds)) return; + if (IsInvincible(Time.time, _lastDamageTime, PlayerMaster.InvincibleSeconds)) return; _lastDamageTime = Time.time; - ApplyHealth(CalculateDamagedHealth(_currentHealth, damage)); + ApplyHealth(CalculateDamagedHealth(_playerService.CurrentHealth, damage)); if (_healthView != null) _healthView.Notify(); - _messagePipeService?.Publish(new HorrorSignals.Player.Damaged(damage, _currentHealth, _maxHealth)); + _messagePipeService?.Publish(new HorrorSignals.Player.Damaged(damage, _playerService.CurrentHealth, _playerService.MaxHealth)); - if (!string.IsNullOrEmpty(_damageSeAssetName)) - _audioService.PlaySoundEffectOneShotAsync(_damageSeAssetName, destroyCancellationToken).Forget(); + if (!string.IsNullOrEmpty(PlayerMaster.DamageSeAssetName)) + _audioService.PlaySoundEffectOneShotAsync(PlayerMaster.DamageSeAssetName, destroyCancellationToken).Forget(); if (IsDead) { @@ -520,7 +424,7 @@ private void UpdateInput() } else { - var baseSpeed = _isCrouching ? _crouchSpeed : (_isSprinting ? _runSpeed : _walkSpeed); + var baseSpeed = _isCrouching ? PlayerMaster.CrouchSpeed : (_isSprinting ? PlayerMaster.RunSpeed : PlayerMaster.WalkSpeed); _speed = _moveValue.magnitude * baseSpeed; } @@ -596,12 +500,13 @@ private bool TryAttack() _attackTriggered = false; - if (_weaponMaster == null) + var weapon = EquippedWeaponMaster; + if (weapon == null) return false; // 弾切れは空撃ち(ステート遷移なし=硬直なし)。AmmoItemId=0 の武器は弾薬概念なし(無限) - if (_weaponMaster.AmmoItemId > 0 - && _equipmentService.GetMagazineCount(_weaponMaster.Id, _weaponMaster.MagazineSize) <= 0) + if (weapon.AmmoItemId > 0 + && _equipmentService.GetMagazineCount(weapon.Id, weapon.MagazineSize) <= 0) { HandleDryFire(); return false; @@ -616,8 +521,9 @@ private bool TryAttack() /// private void HandleDryFire() { - if (!string.IsNullOrEmpty(_weaponMaster.DryFireSeAssetName)) - _audioService.PlaySoundEffectOneShotAsync(_weaponMaster.DryFireSeAssetName, destroyCancellationToken).Forget(); + var weapon = EquippedWeaponMaster; + if (!string.IsNullOrEmpty(weapon.DryFireSeAssetName)) + _audioService.PlaySoundEffectOneShotAsync(weapon.DryFireSeAssetName, destroyCancellationToken).Forget(); NotifyHudViews(); @@ -694,13 +600,14 @@ private bool TryReload() _reloadTriggered = false; - if (_weaponMaster == null || _weaponMaster.AmmoItemId <= 0 || _weaponMaster.MagazineSize <= 0) + var weapon = EquippedWeaponMaster; + if (weapon == null || weapon.AmmoItemId <= 0 || weapon.MagazineSize <= 0) return false; - if (_equipmentService.GetMagazineCount(_weaponMaster.Id, _weaponMaster.MagazineSize) >= _weaponMaster.MagazineSize) + if (_equipmentService.GetMagazineCount(weapon.Id, weapon.MagazineSize) >= weapon.MagazineSize) return false; - if (_inventoryService.GetCount(ObjectCategory.Item, _weaponMaster.AmmoItemId) <= 0) + if (_inventoryService.GetCount(ObjectCategory.Item, weapon.AmmoItemId) <= 0) return false; return true; @@ -733,7 +640,7 @@ private bool TryUseItem() if (_inventoryService.GetCount(_requestedUseCategory, _requestedUseId) <= 0) return false; - // 満タン判定はサービスの共有述語で行う(_currentHealth は ApplyHealth で常時同期済み) + // 満タン判定はサービスの共有述語で行う if (_playerService.IsHealthFull) return false; @@ -741,37 +648,6 @@ private bool TryUseItem() return true; } - /// - /// ショートカット4スロット+現在装備中の Weapon をマスター解決し、武器モデルの事前ロード対象として列挙する。 - /// 同一 Id は重複排除する。 - /// - private List ResolveEquippableMasters() - { - var masters = new List(); - var seenIds = new HashSet(); - - for (var i = 0; i < HorrorEquipmentConstants.MaxEquipmentSlotCount; i++) - { - if (_equipmentService.TryGetSlot(i, out var slot) - && slot.ObjectCategory == ObjectCategory.Weapon - && seenIds.Add(slot.Id) - && _dbService.Database.HorrorWeaponMasterTable.TryFindById(slot.Id, out var slotMaster)) - { - masters.Add(slotMaster); - } - } - - if (_equipmentService.TryGetEquipped(out var equippedType, out var equippedId) - && equippedType == ObjectCategory.Weapon - && seenIds.Add(equippedId) - && _dbService.Database.HorrorWeaponMasterTable.TryFindById(equippedId, out var equippedMaster)) - { - masters.Add(equippedMaster); - } - - return masters; - } - /// /// Hold 長押しの進捗(0→1)を算出する。 が 0 以下なら /// ゼロ除算を避けて即時完了(1)とみなす。表示側で Clamp されるため、 @@ -885,8 +761,8 @@ private bool IsActiveState() where TState : State _characterController.isGrounded; private bool IsMoving() => _speed > 0f; - private bool IsWalking() => _speed >= _walkSpeed && _speed < _runSpeed; - private bool IsRunning() => _speed >= _runSpeed; + private bool IsWalking() => _speed >= PlayerMaster.WalkSpeed && _speed < PlayerMaster.RunSpeed; + private bool IsRunning() => _speed >= PlayerMaster.RunSpeed; private bool IsMoveInput() => _moveValue.magnitude > InputConstants.InputThreshold; private bool IsLookInput() => _lookValue.magnitude > InputConstants.InputThreshold; @@ -936,7 +812,7 @@ private void UpdateCrouchInput() private void UpdateAimInput() { _isAiming = Player.Aim.IsPressed() - && _weaponMaster != null + && EquippedWeaponMaster != null && !IsActiveState() && !IsActiveState(); } @@ -1002,546 +878,6 @@ private bool CanStandUp() #endregion - #region StateMachine - - private void InitializeStateMachine() - { - _stateMachine = new StateMachine(this); - - // 状態遷移テーブルの構築 - _stateMachine.AddTransition(StateEvent.Move); - _stateMachine.AddTransition(StateEvent.Stop); - - _stateMachine.AddTransition(StateEvent.Jump); - _stateMachine.AddTransition(StateEvent.Jump); - - _stateMachine.AddTransition(StateEvent.Land); - - _stateMachine.AddTransition(StateEvent.Interact); - _stateMachine.AddTransition(StateEvent.Interact); - _stateMachine.AddTransition(StateEvent.EndInteract); - - _stateMachine.AddTransition(StateEvent.Attack); - _stateMachine.AddTransition(StateEvent.Attack); - _stateMachine.AddTransition(StateEvent.EndAttack); - - _stateMachine.AddTransition(StateEvent.Equip); - _stateMachine.AddTransition(StateEvent.Equip); - _stateMachine.AddTransition(StateEvent.EndEquip); - - _stateMachine.AddTransition(StateEvent.Reload); - _stateMachine.AddTransition(StateEvent.Reload); - _stateMachine.AddTransition(StateEvent.EndReload); - - _stateMachine.AddTransition(StateEvent.UseItem); - _stateMachine.AddTransition(StateEvent.UseItem); - _stateMachine.AddTransition(StateEvent.EndUseItem); - - _stateMachine.AddTransition(StateEvent.Idle); - _stateMachine.AddTransition(StateEvent.Dead); - - // 初期ステート - _stateMachine.SetInitState(); - } - - /// - /// 状態遷移イベントKey - /// - private enum StateEvent - { - Idle, // 待機状態: Idle - Move, // 移動開始: Idle → Moving - Stop, // 移動停止: Moving → Idle - Jump, // ジャンプ: Idle/Moving → Jumping - Land, // 着地: Jumping → Idle - Interact, // インタラクト開始: Idle/Moving → Interacting - EndInteract, // インタラクト終了: Interacting → Idle - Attack, // 攻撃開始: Idle/Moving → Attacking - EndAttack, // 攻撃終了(発射間隔経過): Attacking → Idle - Equip, // 装備切替開始: Idle/Moving → Equipping - EndEquip, // 装備切替終了(EquipDuration経過): Equipping → Idle - Reload, // リロード開始: Idle/Moving → Reloading - EndReload, // リロード終了(ReloadDuration経過): Reloading → Idle - UseItem, // アイテム使用開始: Idle/Moving → UsingItem - EndUseItem, // アイテム使用終了(EffectApplyDuration経過): UsingItem → Idle - Dead, - } - - private class IdleState : State - { - public override void Update() - { - var ctx = Context; - ctx.UpdateRotation(); - ctx.UpdateCrouchPose(); - ctx.UpdateHeadBob(); - ctx.UpdateAimPose(); - - // ジャンプ入力チェック - if (ctx._jumpTriggered && ctx.IsGrounded()) - { - StateMachine.Transition(StateEvent.Jump); - return; - } - - // インタラクト起動チェック - if (ctx.TryInteraction()) - { - StateMachine.Transition(StateEvent.Interact); - return; - } - - // 攻撃(射撃)起動チェック - if (ctx.TryAttack()) - { - StateMachine.Transition(StateEvent.Attack); - return; - } - - // アイテム使用起動チェック(装備予約と併存した場合は回復を先に実行する) - if (ctx.TryUseItem()) - { - StateMachine.Transition(StateEvent.UseItem); - return; - } - - // 装備切替起動チェック - if (ctx.TryEquip()) - { - StateMachine.Transition(StateEvent.Equip); - return; - } - - // リロード起動チェック - if (ctx.TryReload()) - { - StateMachine.Transition(StateEvent.Reload); - return; - } - - // 移動入力チェック - if (ctx.IsMoveInput()) - { - StateMachine.Transition(StateEvent.Move); - } - } - - public override void FixedUpdate() - { - // 静止中も重力を適用 - Context.UpdateMovementWithGravity(Vector3.zero); - } - } - - private class MovingState : State - { - public override void Update() - { - var ctx = Context; - ctx.UpdateRotation(); - ctx.UpdateCrouchPose(); - ctx.UpdateHeadBob(); - ctx.UpdateAimPose(); - - // ジャンプ入力チェック - if (ctx._jumpTriggered && ctx.IsGrounded()) - { - StateMachine.Transition(StateEvent.Jump); - return; - } - - // インタラクト起動チェック - if (ctx.TryInteraction()) - { - StateMachine.Transition(StateEvent.Interact); - return; - } - - // 攻撃(射撃)起動チェック - if (ctx.TryAttack()) - { - StateMachine.Transition(StateEvent.Attack); - return; - } - - // アイテム使用起動チェック(装備予約と併存した場合は回復を先に実行する) - if (ctx.TryUseItem()) - { - StateMachine.Transition(StateEvent.UseItem); - return; - } - - // 装備切替起動チェック - if (ctx.TryEquip()) - { - StateMachine.Transition(StateEvent.Equip); - return; - } - - // リロード起動チェック - if (ctx.TryReload()) - { - StateMachine.Transition(StateEvent.Reload); - return; - } - - // 移動入力がなくなったらIdleへ - if (!ctx.IsMoveInput()) - { - StateMachine.Transition(StateEvent.Stop); - } - } - - public override void FixedUpdate() - { - var ctx = Context; - ctx.UpdateMovementWithGravity(ctx.ComputeHorizontalVelocity()); - } - } - - private class JumpingState : State - { - public override void Enter() - { - var ctx = Context; - ctx._verticalVelocity = ctx._jump; - ctx._jumpTriggered = false; - } - - public override void Update() - { - var ctx = Context; - ctx.UpdateRotation(); - ctx.UpdateCrouchPose(); - ctx.UpdateHeadBob(); - ctx.UpdateAimPose(); - - // 上昇終了 + 接地で着地判定 - if (ctx._verticalVelocity <= 0f && ctx.IsGrounded()) - { - StateMachine.Transition(StateEvent.Land); - } - } - - public override void FixedUpdate() - { - var ctx = Context; - // 空中でも水平移動を許可 - ctx.UpdateMovementWithGravity(ctx.ComputeHorizontalVelocity()); - } - } - - /// - /// インタラクト実行中の身体占有状態。視点回転とエイム解除の補間のみ許可し水平移動は止める。 - /// 入力タイプを問わず、拒否メッセージ/単発・トグル/長押しを 1 本の非同期シーケンスで処理する。 - /// - private class InteractingState : State - { - private bool _completed; - - public override void Enter() - { - _completed = false; - RunAsync(Context._interactTarget).Forget(); - } - - public override void Update() - { - Context.UpdateRotation(); // 拘束中は視点回転とエイム解除の補間のみ許可 - Context.UpdateAimPose(); - if (_completed) StateMachine.Transition(StateEvent.EndInteract); - } - - // 水平移動なし=拘束(重力のみ適用) - public override void FixedUpdate() => Context.UpdateMovementWithGravity(Vector3.zero); - - public override void Exit() - { - var ctx = Context; - ctx._interactTarget?.SetHoldProgress(0f); // 中断・完了とも即非表示 - ctx._interactTarget = null; - _completed = false; - } - - // 1 回のインタラクトを開始~効果発火まで逐次処理する。 - // 拒否(メッセージ)/単発・トグル(即時)/長押し(進捗)を 1 本のフローで扱う。 - private async UniTask RunAsync(IInteractable target) - { - if (!target.CanInteract()) - { - await target.TryShowRejectionMessage(); - } - else if (target.InputType == InteractionInputType.Hold) - { - await RunHoldAsync(target); - } - else - { - target.Interact(); - } - - _completed = true; - } - - private async UniTask RunHoldAsync(IInteractable target) - { - var ctx = Context; - var elapsed = 0f; - target.SetHoldProgress(0f); - - while (true) - { - // 中断条件:対象喪失 / 視線を外した / ボタン解放 / 実行不可化 / 死亡 - var stillAimed = ctx._interactionDetector != null - && ctx._interactionDetector.TryGetTarget(out var current) - && current == target; - if (!stillAimed || !ctx.Player.Interact.IsPressed() || !target.CanInteract() || ctx.IsDead) - return; - - elapsed += Time.deltaTime; - target.SetHoldProgress(CalculateHoldProgress(elapsed, target.HoldSeconds)); - - if (elapsed >= target.HoldSeconds) - { - target.Interact(); - return; - } - - await UniTask.Yield(PlayerLoopTiming.Update); - } - } - } - - /// - /// 射撃実行中の状態。Enter で 1 発発砲し、FireInterval(武器マスター)の間は移動・視点を許可しつつ - /// 次弾の発射を待たせる(発射レート制限)。間隔を消化したら Idle へ戻る。 - /// - private class AttackingState : State - { - private float _elapsed; - - public override void Enter() - { - // インスタンスはキャッシュ再利用されるため経過時間を必ずリセット - _elapsed = 0f; - Context.Fire(); - } - - public override void Update() - { - var ctx = Context; - ctx.UpdateRotation(); - ctx.UpdateCrouchPose(); - ctx.UpdateHeadBob(); - ctx.UpdateAimPose(); - - _elapsed += Time.deltaTime; - if (_elapsed >= ctx.GetFireInterval()) - StateMachine.Transition(StateEvent.EndAttack); - } - - public override void FixedUpdate() - { - var ctx = Context; - ctx.UpdateMovementWithGravity(ctx.ComputeHorizontalVelocity()); - } - } - - /// - /// 装備切替実行中の状態。Enter で装備をセーブデータへ反映し、EquipDuration(武器マスター)の間は - /// 移動・視点を許可しつつ硬直として滞在する。滞在秒を消化したら Idle へ戻る。 - /// - private class EquippingState : State - { - private float _elapsed; - - public override void Enter() - { - // インスタンスはキャッシュ再利用されるため経過時間を必ずリセット - _elapsed = 0f; - - var ctx = Context; - if (ctx._equipmentService.TryEquip(ctx._pendingEquipType, ctx._pendingEquipId)) - { - ctx._weaponMaster = ctx._pendingWeaponMaster; - ctx._weaponView.BeginSwitch(ctx._pendingWeaponMaster); - ctx._equipmentsView.Show(ctx._pendingEquipType, ctx._pendingEquipId); - Debug.Log($"{ctx._weaponMaster.Name}"); - } - } - - public override void Update() - { - var ctx = Context; - ctx.UpdateRotation(); - ctx.UpdateCrouchPose(); - ctx.UpdateHeadBob(); - - _elapsed += Time.deltaTime; - ctx._weaponView.TickSwitch(_elapsed, ctx._pendingWeaponMaster.EquipDuration); - ctx.UpdateAimPose(); // TickSwitch の後に呼ぶ(下げ量更新 → 位置反映の順序) - if (_elapsed >= ctx._pendingWeaponMaster.EquipDuration) - StateMachine.Transition(StateEvent.EndEquip); - } - - public override void FixedUpdate() - { - var ctx = Context; - ctx.UpdateMovementWithGravity(ctx.ComputeHorizontalVelocity()); - } - } - - /// - /// リロード実行中の状態。ReloadDuration(武器マスター)の間、移動・視点・エイムを許可しつつ硬直として滞在し、 - /// 武器を傾ける演出を進める。滞在秒を消化した時点で装填(弾倉回復・予備消費)を適用して Idle へ戻る。 - /// 攻撃・ジャンプ・インタラクトの起動は入力側・遷移構造で禁止される。 - /// - private class ReloadingState : State - { - private float _elapsed; - private float _duration; - private bool _applied; - - public override void Enter() - { - var ctx = Context; - // インスタンスはキャッシュ再利用されるため経過時間・適用済みフラグを必ずリセット - _elapsed = 0f; - _applied = false; - _duration = ctx._weaponMaster.ReloadDuration; - ctx.NotifyHudViews(); - } - - public override void Update() - { - var ctx = Context; - ctx.UpdateRotation(); - ctx.UpdateCrouchPose(); - ctx.UpdateHeadBob(); - - _elapsed += Time.deltaTime; - ctx._weaponView.TickReload(_elapsed, _duration); - ctx.UpdateAimPose(); // TickReload の後に呼ぶ(傾き量更新 → 反映の順序) - - if (!_applied && _elapsed >= _duration) - { - _applied = true; // フレーム落ち・将来の中断遷移追加に対する二重適用防止 - ctx.ApplyReload(); - StateMachine.Transition(StateEvent.EndReload); - } - } - - public override void FixedUpdate() - { - var ctx = Context; - ctx.UpdateMovementWithGravity(ctx.ComputeHorizontalVelocity()); - } - - public override void Exit() - { - // 中断・完了とも傾き演出を確実に解除する - Context._weaponView.ResetReload(); - } - } - - /// - /// アイテム使用(回復)実行中の状態。EffectApplyDuration(アイテムマスター)の間、移動・視点を許可しつつ - /// 硬直として滞在し、経過比率に応じた回復を毎フレーム差分適用する(漸進適用。完了時一括ではない)。 - /// アイテムは開始時点で消費するため、死亡による中断時は途中までの回復適用・消費済みのまま終わる。 - /// 攻撃・リロード・インタラクトの起動は入力側・遷移構造で禁止される。 - /// - private class UsingItemState : State - { - private float _elapsed; - private int _appliedHeal; - - public override void Enter() - { - var ctx = Context; - // インスタンスはキャッシュ再利用されるため経過時間・適用済み量を必ずリセット - _elapsed = 0f; - _appliedHeal = 0; - - // 開始時点で消費を確定する。TryUseItem の所持検証と同一フレームのため、失敗はデータ異常時のみの防御パス - if (!ctx._inventoryService.TryConsume(ObjectCategory.Item, ctx._pendingUseItemMaster.Id, 1)) - { - Debug.LogError($"アイテム消費に失敗したため使用を中止します Id={ctx._pendingUseItemMaster.Id}", ctx); - StateMachine.Transition(StateEvent.EndUseItem); - } - } - - public override void Update() - { - var ctx = Context; - ctx.UpdateRotation(); - ctx.UpdateCrouchPose(); - ctx.UpdateHeadBob(); - - _elapsed += Time.deltaTime; - ApplyProgressiveHeal(ctx); - ctx.UpdateAimPose(); - - if (_elapsed >= ctx._pendingUseItemMaster.EffectApplyDuration) - StateMachine.Transition(StateEvent.EndUseItem); - } - - public override void FixedUpdate() - { - var ctx = Context; - ctx.UpdateMovementWithGravity(ctx.ComputeHorizontalVelocity()); - } - - public override void Exit() - { - // 中断(死亡)時も残量の一括適用はしない(途中までの回復のみが残る) - Context._pendingUseItemMaster = null; - } - - // 経過比率から適用済み総量を再計算し、前フレームとの差分のみを加算する(丸め誤差の蓄積防止) - private void ApplyProgressiveHeal(HorrorPlayerController ctx) - { - var master = ctx._pendingUseItemMaster; - var applied = CalculateAppliedHeal(master.Effect, _elapsed, master.EffectApplyDuration); - var diff = applied - _appliedHeal; - if (diff <= 0) return; - - _appliedHeal = applied; - ctx.ApplyHealth(CalculateHealedHealth(ctx._currentHealth, diff, ctx._maxHealth)); - } - } - - /// - /// 死亡状態(終端) - /// 復帰は GameOverDialog のシーン遷移=プレイヤー再生成で行われる)。 - /// 入力は UpdateInput の restrained(死亡込み)で遮断し、水平移動を止め重力のみ適用する。 - /// Enter で演出ディレイ → HorrorGameOverDialog.RunAsync のシーケンスを起動する。 - /// - private class DeadState : State - { - public override void Enter() - { - var ctx = Context; - - // 遷移フレームからエイム解除の補間を開始する(他の入力フラグは終端ステートでは読まれないため触らない。 - // 次フレーム以降は UpdateInput の restrained 分岐が毎フレーム解除を保証する) - ctx._isAiming = false; - - // ヘッドボブの残オフセットを rest 位置へ戻す(以降 UpdateHeadBob は呼ばない) - if (ctx._mainCamera != null) - ctx._mainCamera.transform.localPosition = ctx._cameraBasePosition; - - ctx.RunGameOverAsync().Forget(); - } - - // 視点回転なし。エイム解除補間・FOV 復帰・残弾 HUD フェードアウトのみ進める - public override void Update() => Context.UpdateAimPose(); - - // 水平移動なし=拘束(重力のみ適用)— InteractingState と同じ - public override void FixedUpdate() => Context.UpdateMovementWithGravity(Vector3.zero); - } - - #endregion - #region Movement /// @@ -1575,7 +911,7 @@ private void UpdateMovementWithGravity(Vector3 horizontalVelocity) } else { - _verticalVelocity += _gravity * Time.fixedDeltaTime; + _verticalVelocity += PlayerMaster.Gravity * Time.fixedDeltaTime; } var motion = horizontalVelocity + Vector3.up * _verticalVelocity; @@ -1601,7 +937,7 @@ private void UpdateFootstep(Vector3 positionBeforeMove) var delta = transform.position - positionBeforeMove; delta.y = 0f; - var (fired, next) = StepFootstep(_footstepAccumulatedDistance, delta.magnitude, _footstepStride); + var (fired, next) = StepFootstep(_footstepAccumulatedDistance, delta.magnitude, PlayerMaster.FootstepStride); if (fired) EmitFootstep(); @@ -1614,12 +950,12 @@ private void UpdateFootstep(Vector3 positionBeforeMove) /// private void EmitFootstep() { - var loudness = CalculateFootstepLoudness(IsRunning(), _footstepWalkLoudness, _footstepRunLoudness); + var loudness = CalculateFootstepLoudness(IsRunning(), PlayerMaster.FootstepWalkLoudness, PlayerMaster.FootstepRunLoudness); if (loudness > 0f) _messagePipeService?.Publish(new HorrorSignals.Noise.Occurred(transform.position, loudness, NoiseType.Footstep)); - if (!string.IsNullOrEmpty(_footstepSeAssetName)) - _audioService.PlaySoundEffectOneShotAsync(_footstepSeAssetName, destroyCancellationToken).Forget(); + if (!string.IsNullOrEmpty(PlayerMaster.FootstepSeAssetName)) + _audioService.PlaySoundEffectOneShotAsync(PlayerMaster.FootstepSeAssetName, destroyCancellationToken).Forget(); } // カメラ localEulerAngles へ書き込む際は常にこの表示用 pitch を使う(リコイル合成の単一点) @@ -1635,15 +971,15 @@ private void UpdateRotation() if (_mainCamera == null) return; // エイム中はカメラ回転を減速する(精密な狙いを支援) - var aimMultiplier = Mathf.Lerp(1f, _aimRotationMultiplier, _aimBlend); + var aimMultiplier = Mathf.Lerp(1f, PlayerMaster.AimRotationMultiplier, _aimBlend); // Yaw: Player 本体を Y 軸回転(感度H・反転を適用、入力は加速度スムージング後の値) var horizontalInput = _smoothedLookValue.x * _lookSensitivityX * _lookInvertX; - transform.Rotate(0f, horizontalInput * _lookRotationSpeed * aimMultiplier, 0f, Space.Self); + transform.Rotate(0f, horizontalInput * PlayerMaster.LookRotationSpeed * aimMultiplier, 0f, Space.Self); // Pitch: カメラの X 軸 localEulerAngles を更新、クランプ(既定 -y、感度V・反転を適用) var verticalInput = -_smoothedLookValue.y * _lookSensitivityY * _lookInvertY; - _cameraVerticalAngle = Mathf.Clamp(_cameraVerticalAngle + verticalInput * _lookRotationSpeed * aimMultiplier, -89f, 89f); + _cameraVerticalAngle = Mathf.Clamp(_cameraVerticalAngle + verticalInput * PlayerMaster.LookRotationSpeed * aimMultiplier, -89f, 89f); // 発砲リコイルの減衰(全ステートの Update から毎フレーム呼ばれるためここで駆動する) _recoilWeight = Mathf.MoveTowards(_recoilWeight, 0f, Time.deltaTime / Mathf.Max(_recoilRecoverSeconds, 0.0001f)); @@ -1653,7 +989,7 @@ private void UpdateRotation() /// /// カメラ揺れを適用。移動中は figure-8 ヘッドボブ、停止中はアイドルスウェイ(呼吸揺れ)をクロスフェードする。 - /// 全体強度は CameraShake でスケール。ApplyRotation 直後に呼ばれ、表示用 pitch(リコイル込み)を維持しつつ roll を合成する。 + /// 全体強度は CameraShake でスケール。UpdateRotation・UpdateCrouchPose の後に呼ばれ、表示用 pitch(リコイル込み)を維持しつつ roll を合成する。 /// private void UpdateHeadBob() { @@ -1664,7 +1000,7 @@ private void UpdateHeadBob() { _mainCamera.transform.localPosition = _cameraBasePosition; _mainCamera.transform.localEulerAngles = new Vector3(GetDisplayPitch(), 0f, 0f); - _moveBobWeight = 0f; + _moveHeadBobWeight = 0f; return; } @@ -1673,29 +1009,29 @@ private void UpdateHeadBob() var active = IsGrounded() && IsMoving(); var running = IsRunning(); - var ease = 1f - Mathf.Exp(-_bobAmplitudeResponse * Time.deltaTime); - _moveBobWeight = Mathf.Lerp(_moveBobWeight, active ? 1f : 0f, ease); + var ease = 1f - Mathf.Exp(-PlayerMaster.HeadBobAmplitudeResponse * Time.deltaTime); + _moveHeadBobWeight = Mathf.Lerp(_moveHeadBobWeight, active ? 1f : 0f, ease); if (active) - _bobPhase += (running ? _bobRunSpeed : _bobWalkSpeed) * Time.deltaTime; - _idlePhase += _idleSwaySpeed * Time.deltaTime; // アイドルは常時進む + _headBobPhase += (running ? PlayerMaster.HeadBobRunSpeed : PlayerMaster.HeadBobWalkSpeed) * Time.deltaTime; + _idlePhase += PlayerMaster.IdleSwaySpeed * Time.deltaTime; // アイドルは常時進む // ヘッドボブ(移動):縦は位相、横はストライド(半周期)=figure-8。横揺れの知覚はロールが主成分。 - var moveAmplitude = (running ? _bobRunAmplitude : _bobWalkAmplitude) * _moveBobWeight; - var moveRoll = (running ? _bobRunRoll : _bobWalkRoll) * _moveBobWeight; - var bobX = Mathf.Sin(_bobPhase * 0.5f) * moveAmplitude * _bobHorizontalRatio; - var bobY = Mathf.Sin(_bobPhase) * moveAmplitude; - var bobRoll = Mathf.Sin(_bobPhase * 0.5f) * moveRoll; + var moveAmplitude = (running ? PlayerMaster.HeadBobRunAmplitude : PlayerMaster.HeadBobWalkAmplitude) * _moveHeadBobWeight; + var moveRoll = (running ? PlayerMaster.HeadBobRunRoll : PlayerMaster.HeadBobWalkRoll) * _moveHeadBobWeight; + var headBobX = Mathf.Sin(_headBobPhase * 0.5f) * moveAmplitude * PlayerMaster.HeadBobHorizontalRatio; + var headBobY = Mathf.Sin(_headBobPhase) * moveAmplitude; + var headBobRoll = Mathf.Sin(_headBobPhase * 0.5f) * moveRoll; // アイドルスウェイ(停止):別周波数の遅い sin を重ねて有機的に - var idleWeight = 1f - _moveBobWeight; - var idleX = Mathf.Sin(_idlePhase * 1.3f) * _idleSwayAmplitude * _bobHorizontalRatio * idleWeight; - var idleY = Mathf.Sin(_idlePhase) * _idleSwayAmplitude * idleWeight; - var idleRoll = Mathf.Sin(_idlePhase * 0.7f) * _idleSwayRoll * idleWeight; + var idleWeight = 1f - _moveHeadBobWeight; + var idleX = Mathf.Sin(_idlePhase * 1.3f) * PlayerMaster.IdleSwayAmplitude * PlayerMaster.HeadBobHorizontalRatio * idleWeight; + var idleY = Mathf.Sin(_idlePhase) * PlayerMaster.IdleSwayAmplitude * idleWeight; + var idleRoll = Mathf.Sin(_idlePhase * 0.7f) * PlayerMaster.IdleSwayRoll * idleWeight; // 合算 → 全体強度 CameraShake × エイム減衰(エイム中は _aimShakeWeight が 0 へ減衰) - var offset = new Vector3(bobX + idleX, bobY + idleY, 0f) * _cameraShake * _aimShakeWeight; - var roll = (bobRoll + idleRoll) * _cameraShake * _aimShakeWeight; + var offset = new Vector3(headBobX + idleX, headBobY + idleY, 0f) * _cameraShake * _aimShakeWeight; + var roll = (headBobRoll + idleRoll) * _cameraShake * _aimShakeWeight; _mainCamera.transform.localPosition = _cameraBasePosition + offset; _mainCamera.transform.localEulerAngles = new Vector3(GetDisplayPitch(), 0f, roll); @@ -1710,10 +1046,10 @@ private void UpdateCrouchPose() { // 目標 0/1 へ指数補間(フレームレート非依存) var target = _isCrouching ? 1f : 0f; - var ease = 1f - Mathf.Exp(-_crouchTransitionSpeed * Time.deltaTime); + var ease = 1f - Mathf.Exp(-PlayerMaster.CrouchTransitionSpeed * Time.deltaTime); _crouchBlend = Mathf.Lerp(_crouchBlend, target, ease); - var height = Mathf.Lerp(_standHeight, _crouchHeight, _crouchBlend); + var height = Mathf.Lerp(_standHeight, PlayerMaster.CrouchHeight, _crouchBlend); // カプセル下端(= center.y - height/2)を立ち時と同じに固定し、足元を保ったまま頭だけ縮める var centerY = (height - _standHeight) * 0.5f; @@ -1738,13 +1074,13 @@ private void UpdateAimPose() // 目標 0/1 へ指数補間(フレームレート非依存) var target = _isAiming ? 1f : 0f; - var ease = 1f - Mathf.Exp(-_aimTransitionSpeed * Time.deltaTime); + var ease = 1f - Mathf.Exp(-PlayerMaster.AimTransitionSpeed * Time.deltaTime); _aimBlend = Mathf.Lerp(_aimBlend, target, ease); ApplyFov(); - // カメラ揺れの重みを線形に減衰/復帰(_aimShakeFadeSeconds でゼロ/1 に到達) - _aimShakeWeight = Mathf.MoveTowards(_aimShakeWeight, _isAiming ? 0f : 1f, Time.deltaTime / _aimShakeFadeSeconds); + // カメラ揺れの重みを線形に減衰/復帰(AimShakeFadeSeconds でゼロ/1 に到達) + _aimShakeWeight = Mathf.MoveTowards(_aimShakeWeight, _isAiming ? 0f : 1f, Time.deltaTime / PlayerMaster.AimShakeFadeSeconds); _weaponView.UpdatePose(_aimBlend); @@ -1767,7 +1103,8 @@ private void UpdateAmmoView(bool keepVisible) { if (_ammoView == null) return; - var mode = HorrorAmmoView.ResolveViewMode(_weaponMaster != null, _weaponMaster?.AmmoItemId ?? 0); + var weapon = EquippedWeaponMaster; + var mode = HorrorAmmoView.ResolveViewMode(weapon != null, weapon?.AmmoItemId ?? 0); var magazine = 0; var magazineSize = 0; @@ -1775,12 +1112,12 @@ private void UpdateAmmoView(bool keepVisible) switch (mode) { case HorrorAmmoViewMode.MagazineAndReserve: - magazineSize = _weaponMaster.MagazineSize; - magazine = _equipmentService.GetMagazineCount(_weaponMaster.Id, magazineSize); - reserve = _inventoryService.GetCount(ObjectCategory.Item, _weaponMaster.AmmoItemId); + magazineSize = weapon.MagazineSize; + magazine = _equipmentService.GetMagazineCount(weapon.Id, magazineSize); + reserve = _inventoryService.GetCount(ObjectCategory.Item, weapon.AmmoItemId); break; case HorrorAmmoViewMode.CountOnly: - magazine = _inventoryService.GetCount(ObjectCategory.Weapon, _weaponMaster.Id); // 武器アイテム自体の所持数(例: Smoke) + magazine = _inventoryService.GetCount(ObjectCategory.Weapon, weapon.Id); // 武器アイテム自体の所持数(例: Smoke) break; } @@ -1814,7 +1151,7 @@ private void NotifyHudViews() private void ApplyFov() { if (_mainCamera == null) return; - var zoomRatio = _weaponMaster?.AimZoomRatio ?? 1f; + var zoomRatio = EquippedWeaponMaster?.AimZoomRatio ?? 1f; _mainCamera.fieldOfView = Mathf.Lerp(_baseFov, _baseFov * zoomRatio, _aimBlend); } @@ -1829,7 +1166,8 @@ private void ApplyFov() /// private void Fire() { - if (_mainCamera == null || _weaponMaster == null) return; + var weapon = EquippedWeaponMaster; + if (_mainCamera == null || weapon == null) return; var origin = _mainCamera.transform.position; var direction = _mainCamera.transform.forward; @@ -1837,25 +1175,25 @@ private void Fire() // 非エイム(腰だめ)射撃はわずかにランダム拡散する(エイム中はカメラ中心へ正確に飛ぶ) if (!_isAiming) { - direction = CalculateShotDirection(direction, Random.insideUnitSphere, _weaponMaster.SpreadAngle); + direction = CalculateShotDirection(direction, Random.insideUnitSphere, weapon.SpreadAngle); } IDamageable target = null; - var impactPosition = origin + direction * _weaponMaster.Range; + var impactPosition = origin + direction * weapon.Range; - if (Physics.Raycast(origin, direction, out var hit, _weaponMaster.Range, _hitMask, QueryTriggerInteraction.Ignore)) + if (Physics.Raycast(origin, direction, out var hit, weapon.Range, _hitMask, QueryTriggerInteraction.Ignore)) { target = hit.collider.GetComponentInParent(); impactPosition = hit.point; } - var damage = CalculateAimedDamage(_weaponMaster.Damage, _isAiming, _weaponMaster.AimDamageMultiplier); + var damage = CalculateAimedDamage(weapon.Damage, _isAiming, weapon.AimDamageMultiplier); // 弾倉消費(AmmoItemId=0 の武器は弾薬概念なし・無限) - if (_weaponMaster.AmmoItemId > 0) + if (weapon.AmmoItemId > 0) { - var magazine = _equipmentService.GetMagazineCount(_weaponMaster.Id, _weaponMaster.MagazineSize); - _equipmentService.SetMagazineCount(_weaponMaster.Id, magazine - 1); + var magazine = _equipmentService.GetMagazineCount(weapon.Id, weapon.MagazineSize); + _equipmentService.SetMagazineCount(weapon.Id, magazine - 1); NotifyHudViews(); } @@ -1867,28 +1205,28 @@ private void Fire() // 騒音: 着弾音(着弾点・誘引用)→ 発砲音(射手位置)の順で発行する。この順序は変更不可: // 敵の注意対象位置は同フレームでは後着優先のため、両方聞こえた敵の注意対象は発砲音(射手位置)で確定する - if (_weaponMaster.ImpactNoiseLoudness > 0f) - _messagePipeService?.Publish(new HorrorSignals.Noise.Occurred(impactPosition, _weaponMaster.ImpactNoiseLoudness, NoiseType.Object)); - if (_weaponMaster.NoiseLoudness > 0f) - _messagePipeService?.Publish(new HorrorSignals.Noise.Occurred(origin, _weaponMaster.NoiseLoudness, NoiseType.Gunshot)); + if (weapon.ImpactNoiseLoudness > 0f) + _messagePipeService?.Publish(new HorrorSignals.Noise.Occurred(impactPosition, weapon.ImpactNoiseLoudness, NoiseType.Object)); + if (weapon.NoiseLoudness > 0f) + _messagePipeService?.Publish(new HorrorSignals.Noise.Occurred(origin, weapon.NoiseLoudness, NoiseType.Gunshot)); if (_reticleView != null) _reticleView.NotifyFired(); // 発砲演出:武器ビュー(マズルフラッシュ+キック)・カメラリコイル・射撃音 if (_weaponView != null) _weaponView.NotifyFired(); - _recoilPitchAmount = _weaponMaster.RecoilCameraPitch; - _recoilRecoverSeconds = _weaponMaster.RecoilRecoverSeconds; + _recoilPitchAmount = weapon.RecoilCameraPitch; + _recoilRecoverSeconds = weapon.RecoilRecoverSeconds; _recoilWeight = 1f; - if (!string.IsNullOrEmpty(_weaponMaster.FireSeAssetName)) - _audioService.PlaySoundEffectOneShotAsync(_weaponMaster.FireSeAssetName, destroyCancellationToken).Forget(); + if (!string.IsNullOrEmpty(weapon.FireSeAssetName)) + _audioService.PlaySoundEffectOneShotAsync(weapon.FireSeAssetName, destroyCancellationToken).Forget(); - Debug.Log($"Weapon Fire: name->{_weaponMaster.Name} , damage->{damage}"); + Debug.Log($"Weapon Fire: name->{weapon.Name} , damage->{damage}"); } /// 次弾までの発射間隔(AttackingState 滞在秒)。武器未設定なら 0。 - private float GetFireInterval() => _weaponMaster?.FireInterval ?? 0f; + private float GetFireInterval() => EquippedWeaponMaster?.FireInterval ?? 0f; /// /// 装填を適用する。完了時点の弾倉・予備から装填数を再計算し、予備の消費に成功した場合のみ弾倉へ反映する @@ -1896,17 +1234,18 @@ private void Fire() /// private void ApplyReload() { - if (_weaponMaster == null || _weaponMaster.AmmoItemId <= 0) return; + var weapon = EquippedWeaponMaster; + if (weapon == null || weapon.AmmoItemId <= 0) return; - var magazineSize = _weaponMaster.MagazineSize; - var magazine = _equipmentService.GetMagazineCount(_weaponMaster.Id, magazineSize); - var reserve = _inventoryService.GetCount(ObjectCategory.Item, _weaponMaster.AmmoItemId); + var magazineSize = weapon.MagazineSize; + var magazine = _equipmentService.GetMagazineCount(weapon.Id, magazineSize); + var reserve = _inventoryService.GetCount(ObjectCategory.Item, weapon.AmmoItemId); var amount = CalculateReloadAmount(magazine, magazineSize, reserve); if (amount <= 0) return; - if (!_inventoryService.TryConsume(ObjectCategory.Item, _weaponMaster.AmmoItemId, amount)) return; + if (!_inventoryService.TryConsume(ObjectCategory.Item, weapon.AmmoItemId, amount)) return; - _equipmentService.SetMagazineCount(_weaponMaster.Id, magazine + amount); + _equipmentService.SetMagazineCount(weapon.Id, magazine + amount); NotifyHudViews(); } diff --git a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Player/HorrorPlayerStart.cs b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Player/HorrorPlayerStart.cs index 826870e67..5f1b4ebcc 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Player/HorrorPlayerStart.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Player/HorrorPlayerStart.cs @@ -1,6 +1,7 @@ +using System; using Cysharp.Threading.Tasks; using Game.Core.Services; -using Game.Shared.Extensions; +using Game.Shared.Scriptable.Database.Tables; using Game.Shared.Services; using UnityEngine; @@ -13,10 +14,17 @@ public class HorrorPlayerStart : MonoBehaviour { private GameObject _player; - public async UniTask LoadPlayerAsync() + /// + /// 解決済みのプレイヤーマスターが指すモデルを生成する。生成に使うアドレスがマスター由来のため、 + /// マスターの解決前には呼べない(引数で受けることでその順序を署名に表す)。 + /// + public async UniTask LoadPlayerAsync(HorrorPlayerMaster master) { + if (string.IsNullOrEmpty(master.ModelAssetName)) + throw new InvalidOperationException($"{nameof(HorrorPlayerMaster)}(Id={master.Id}) の {nameof(master.ModelAssetName)} が未設定です"); + var assetService = GameServiceManager.Resolve(); - _player = await assetService.InstantiateAsync("HorrorPlayer", transform); + _player = await assetService.InstantiateAsync(master.ModelAssetName, transform); if (_player.TryGetComponent(out var playerController)) { return playerController; diff --git a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/SaveData/HorrorPlayerSaveData.cs b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/SaveData/HorrorPlayerSaveData.cs index 771951cab..64b0a6e3a 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/SaveData/HorrorPlayerSaveData.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/SaveData/HorrorPlayerSaveData.cs @@ -1,3 +1,4 @@ +using Game.Horror.Constants; using MemoryPack; namespace Game.Horror.SaveData @@ -8,8 +9,8 @@ namespace Game.Horror.SaveData [MemoryPackable] public partial class HorrorPlayerSaveData { - /// 最後に使ったセーブポイントの InteractionId(0 = 未記録) - public int LastSavepointId { get; set; } + /// 操作するプレイヤーの Id(HorrorPlayerMaster の Id)。マスター不在の値は参照側が既定 Id へフォールバックする + public int PlayerId { get; set; } = HorrorSaveConstants.DefaultPlayerId; /// 残 HP(0 = 旧セーブ・未記録。ロード時に最大 HP へ正規化される) /// MemoryPack は宣言順で直列化するため、後方互換のため末尾にのみ追加すること。 diff --git a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/SaveData/HorrorSaveData.cs b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/SaveData/HorrorSaveData.cs index 8e2a41167..b3c016de1 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/SaveData/HorrorSaveData.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/SaveData/HorrorSaveData.cs @@ -20,7 +20,7 @@ public partial class HorrorSaveData /// 保存日時(UTC) public DateTime SavedAtUtc { get; set; } - /// 保存時点のセーブポイント Id(HorrorInteractionMaster の Id、0 = なし) + /// 最後にセーブしたセーブポイントの Id(HorrorInteractionMaster の Id、0 = 未記録)。復帰地点の解決に使う public int SavepointId { get; set; } public HorrorPlayerSaveData Player { get; set; } = new(); diff --git a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Scenes/HorrorStageScene.cs b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Scenes/HorrorStageScene.cs index 2fa04b960..6b3f53a9e 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Scenes/HorrorStageScene.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Scenes/HorrorStageScene.cs @@ -26,6 +26,7 @@ public class HorrorStageScene : GamePrefabScene(); private readonly IGameSceneService _sceneService = GameServiceManager.Resolve(); private readonly IInputSystemService _inputService = GameServiceManager.Resolve(); + private readonly IHorrorSaveRepository _saveRepository = GameServiceManager.Resolve(); private readonly IHorrorOptionSaveRepository _optionSaveRepository = GameServiceManager.Resolve(); private readonly IHorrorPlayerService _playerService = GameServiceManager.Resolve(); @@ -88,7 +89,10 @@ private async UniTask LoadPlayerAsync() if (_playerStart == null) return null; - _player = await _playerStart.LoadPlayerAsync(); + if (!_playerService.ResolvePlayerMaster()) + return null; + + _player = await _playerStart.LoadPlayerAsync(_playerService.PlayerMaster); _player.Initialize(_optionSaveRepository.Data); ApplyRespawnPosition(_player); _optionSaveRepository.OnSaved @@ -111,7 +115,7 @@ private void UnloadPlayer() /// private void ApplyRespawnPosition(HorrorPlayerController player) { - var savepointId = _playerService.LastSavepointId; + var savepointId = _saveRepository.Data.SavepointId; if (savepointId == 0) return; diff --git a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/HorrorEquipmentService.cs b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/HorrorEquipmentService.cs index ca35d6b37..fc9e883c5 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/HorrorEquipmentService.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/HorrorEquipmentService.cs @@ -3,6 +3,8 @@ using Game.Horror.SaveData; using Game.Horror.Services.Interfaces; using Game.Shared.Enums; +using Game.Shared.Scriptable.Database.Tables; +using Game.Shared.Services; using R3; using UnityEngine; @@ -18,11 +20,15 @@ public class HorrorEquipmentService : IHorrorEquipmentService private readonly IHorrorSaveRepository _repository; private readonly IHorrorInventoryService _inventoryService; + private readonly IScriptableDatabaseService _databaseService; - public HorrorEquipmentService(IHorrorSaveRepository repository, IHorrorInventoryService inventoryService) + private HorrorWeaponMaster _equippedWeaponMaster; + + public HorrorEquipmentService(IHorrorSaveRepository repository, IHorrorInventoryService inventoryService, IScriptableDatabaseService databaseService) { _repository = repository; _inventoryService = inventoryService; + _databaseService = databaseService; } /// @@ -31,8 +37,8 @@ public HorrorEquipmentService(IHorrorSaveRepository repository, IHorrorInventory public bool CanEquip(ObjectCategory type, int id) => type == ObjectCategory.Weapon && _inventoryService.HasObject(type, id); /// - /// 指定 (SlotType, Id) を装備状態にする。 が成立する場合のみ反映して Dirty にする。 - /// 現在と同一の装備を指定した場合も冪等に true を返す。 + /// 指定 (SlotType, Id) を装備状態にする。 が成立し、かつマスターを解決できる場合のみ + /// 反映して Dirty にする。現在と同一の装備を指定した場合も冪等に true を返す。 /// public bool TryEquip(ObjectCategory type, int id) { @@ -40,8 +46,16 @@ public bool TryEquip(ObjectCategory type, int id) if (data == null || !CanEquip(type, id)) return false; + // 記録とマスターの確定は同時に行う(装備中なのにマスターが無い状態を作らない) + if (!_databaseService.Database.HorrorWeaponMasterTable.TryFindById(id, out var master)) + { + Debug.LogError($"装備中の武器マスターが見つかりません Id={id}"); + return false; + } + data.ObjectCategory = type; data.Id = id; + _equippedWeaponMaster = master; _repository.MarkDirty(); return true; } @@ -61,6 +75,59 @@ public bool TryGetEquipped(out ObjectCategory type, out int id) return true; } + /// 装備中武器のマスター(null = 未装備。 前も null)。 + public HorrorWeaponMaster EquippedWeaponMaster => _equippedWeaponMaster; + + /// + /// セーブデータの装備記録からマスターを確定する。プレイ開始時(セーブのロード・新規作成後)に呼ぶ。 + /// ロード時は NormalizeEquipment がマスター不在装備を None へ正規化済みのため、解決失敗=不変条件違反。 + /// + public void ResolveEquippedWeaponMaster() + { + _equippedWeaponMaster = null; + + var data = _repository.Data?.Equipment; + if (data == null || data.ObjectCategory != ObjectCategory.Weapon) + return; // 未ロード・未装備 + + if (_databaseService.Database.HorrorWeaponMasterTable.TryFindById(data.Id, out _equippedWeaponMaster)) + return; + + // 記録と実体の乖離を残さない(次回ロード時の NormalizeEquipment と同じ結果へ寄せる) + Debug.LogError($"装備中の武器マスターが見つかりません Id={data.Id}。未装備へ戻します"); + data.ObjectCategory = ObjectCategory.None; + data.Id = 0; + _repository.MarkDirty(); + } + + /// + /// ショートカット登録+装備中の武器をマスター解決し、同一 Id を重複排除して列挙する(スロット0→3→装備中の順)。 + /// マスター未解決のスロット登録は無音でスキップする(ロード時正規化で通常は発生しない)。 + /// + public List GetEquippableWeaponMasters() + { + var masters = new List(); + + for (var i = 0; i < MaxEquipmentSlotCount; i++) + { + if (TryGetSlot(i, out var slot) + && slot.ObjectCategory == ObjectCategory.Weapon + && !masters.Exists(m => m.Id == slot.Id) + && _databaseService.Database.HorrorWeaponMasterTable.TryFindById(slot.Id, out var slotMaster)) + { + masters.Add(slotMaster); + } + } + + var equippedWeapon = EquippedWeaponMaster; + if (equippedWeapon != null && !masters.Exists(m => m.Id == equippedWeapon.Id)) + { + masters.Add(equippedWeapon); + } + + return masters; + } + /// 指定スロット(0-3)へアイテム (SlotType, Id) を登録する。 public bool TrySetSlot(int index, ObjectCategory slotType, int id) { diff --git a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/HorrorPlayerService.cs b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/HorrorPlayerService.cs index 163e9f93f..997a08478 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/HorrorPlayerService.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/HorrorPlayerService.cs @@ -1,66 +1,86 @@ +using Game.Horror.Constants; using Game.Horror.Services.Interfaces; +using Game.Shared.Scriptable.Database.Tables; +using Game.Shared.Services; +using UnityEngine; namespace Game.Horror.Services { /// - /// Horror プレイヤー状態(復帰地点等)を扱うドメインサービス。 - /// 座標ではなく InteractionId のみを永続化し、位置解決はシーン側(リスポーン Transform)に委ねる。 + /// Horror プレイヤー状態を扱うドメインサービス。操作対象のプレイヤーマスターの解決・保持と、残 HP の永続化を担う。 /// public class HorrorPlayerService : IHorrorPlayerService { private readonly IHorrorSaveRepository _repository; + private readonly IScriptableDatabaseService _databaseService; - public HorrorPlayerService(IHorrorSaveRepository repository) + // 今回のプレイで操作するプレイヤーのマスター。ResolvePlayerMaster で確定し、プレイ中は変わらない + private HorrorPlayerMaster _playerMaster; + + public HorrorPlayerService(IHorrorSaveRepository repository, IScriptableDatabaseService databaseService) { _repository = repository; + _databaseService = databaseService; } - /// 最後に使ったセーブポイントの InteractionId(0 = 未記録・未ロード)。 - public int LastSavepointId => _repository.Data?.Player?.LastSavepointId ?? 0; + /// 現在プレイ中のプレイヤーマスター( 前・解決失敗時は null)。 + public HorrorPlayerMaster PlayerMaster => _playerMaster; /// - /// 最後に使ったセーブポイントを記録する。未ロード・Id 0・同値の場合は何もしない(同値で Dirty にしない)。 + /// セーブデータの PlayerId から操作対象のマスターを確定する。プレイヤーの生成に先立って呼ぶ。 + /// 要求 Id が引けなければ既定 Id へフォールバックし、記録と実体の乖離を残さないようセーブデータの + /// PlayerId も既定 Id へ合わせる(LogWarning)。既定 Id も引けない場合はマスターデータ側の + /// 不変条件違反として LogError の上 false を返す(プレイヤーを生成できない)。 /// - public void SetLastSavepoint(int interactionId) + public bool ResolvePlayerMaster() { var data = _repository.Data?.Player; - if (data == null || interactionId == 0 || data.LastSavepointId == interactionId) - return; + var requestedId = data?.PlayerId ?? HorrorSaveConstants.DefaultPlayerId; - data.LastSavepointId = interactionId; - _repository.MarkDirty(); + if (_databaseService.Database.HorrorPlayerMasterTable.TryFindById(requestedId, out _playerMaster)) + return true; + + if (!_databaseService.Database.HorrorPlayerMasterTable.TryFindById(HorrorSaveConstants.DefaultPlayerId, out _playerMaster)) + { + Debug.LogError($"プレイヤーマスターが見つかりません Id={requestedId}(既定 Id={HorrorSaveConstants.DefaultPlayerId} も未登録)"); + return false; + } + + Debug.LogWarning($"プレイヤーマスターが見つかりません Id={requestedId}。既定 Id={_playerMaster.Id} で代替します"); + + if (data != null) + { + data.PlayerId = _playerMaster.Id; + _repository.MarkDirty(); + } + + return true; } - /// 残 HP(0 = 未記録・未ロード。復元側で最大 HP へ正規化する)。 public int CurrentHealth => _repository.Data?.Player?.CurrentHealth ?? 0; + public int MaxHealth => _playerMaster?.MaxHealth ?? 0; + + public bool IsHealthFull => MaxHealth > 0 && CurrentHealth >= MaxHealth; + /// - /// 残 HP を記録する。未ロード・同値の場合は何もしない(同値で Dirty にしない)。 + /// 残 HP を記録する。未ロード時は LogError の上で何もしない。同値の場合も何もしない(同値で Dirty にしない)。 /// 0 も有効値として記録する(死亡時。ゲームオーバー後は Continue/Load でデータごと置き換わる)。 /// public void SetCurrentHealth(int health) { var data = _repository.Data?.Player; - if (data == null || data.CurrentHealth == health) + if (data == null) + { + Debug.LogError($"セーブデータ未ロードのため {nameof(SetCurrentHealth)}({health}) を無視しました"); + return; + } + + if (data.CurrentHealth == health) return; data.CurrentHealth = health; _repository.MarkDirty(); } - - /// - /// 最大 HP(0 = 未設定)。他メンバーと異なりマスタ由来のランタイム値であり、 - /// セーブリポジトリを経由しない(Dirty 化もしない)。 - /// - public int MaxHealth { get; private set; } - - /// 最大 HP を記録する。プレイヤー初期化時にマスタ適用後の実効値を共有する。 - public void SetMaxHealth(int maxHealth) - { - MaxHealth = maxHealth; - } - - /// HP が満タンで回復アイテムを使用できないか。MaxHealth 未設定(0 以下)は満タン扱いにしない。 - public bool IsHealthFull => MaxHealth > 0 && CurrentHealth >= MaxHealth; } } diff --git a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/HorrorSaveRepository.cs b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/HorrorSaveRepository.cs index 3c87207f5..a611c7f2d 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/HorrorSaveRepository.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/HorrorSaveRepository.cs @@ -81,7 +81,7 @@ public async UniTask LoadBySlotAsync(int slotNo) /// /// 指定スロットへ保存する。範囲外のスロット番号は保存を行わない。 - /// スロットメタ(スロット番号・保存日時・セーブポイント Id)は保存直前に が刻印する。 + /// スロットメタ(スロット番号・保存日時)は保存直前に が刻印する。 /// /// 保存先スロット番号(0〜 - 1)。 public async UniTask SaveBySlotAsync(int slotNo) @@ -119,15 +119,31 @@ private bool IsValidSlot(int slotNo) return true; } + /// + /// セーブポイントIDを記録する + /// + public void SetSavepointId(int interactionId) + { + if (Data == null) + { + Debug.LogError($"[{GetType().Name}] セーブデータ未ロードのため {nameof(SetSavepointId)}({interactionId}) を無視しました"); + return; + } + + if (interactionId == 0 || Data.SavepointId == interactionId) + return; + + Data.SavepointId = interactionId; + MarkDirty(); + } + // どの保存経路(SaveAsync / SaveIfDirtyAsync)でもスロットメタが最新になるよう、保存直前に刻印する。 - // セーブポイント Id は復帰地点(Player.LastSavepointId)から導出し、真実の源を一つに保つ。 protected override void OnBeforeSave(HorrorSaveData data) { if (CurrentSlot < 0) throw new InvalidOperationException($"[{GetType().Name}] Slot {CurrentSlot} is invalid"); data.SlotNo = CurrentSlot; data.SavedAtUtc = DateTime.UtcNow; - data.SavepointId = data.Player.LastSavepointId; } protected override HorrorSaveData CreateNewData() @@ -147,19 +163,19 @@ protected override void OnDataLoaded(HorrorSaveData data) var database = _databaseService.Database; - NormalizePlayer(data.Player, database); + NormalizeSavepoint(data, database); NormalizeInteraction(data.Interaction, database); NormalizeInventory(data.Inventory, database); NormalizeEquipment(data.Equipment, database); NormalizeKeyItem(data.KeyItem, database); } - private static void NormalizePlayer(HorrorPlayerSaveData data, ScriptableDatabase database) + private static void NormalizeSavepoint(HorrorSaveData data, ScriptableDatabase database) { // マスター不在 Id は未記録(0)へ戻す。シーン内に該当セーブポイントが無いケースは復元側のフォールバックが担う - if (data.LastSavepointId != 0 && !database.HorrorInteractionMasterTable.TryFindById(data.LastSavepointId, out _)) + if (data.SavepointId != 0 && !database.HorrorInteractionMasterTable.TryFindById(data.SavepointId, out _)) { - data.LastSavepointId = 0; + data.SavepointId = 0; } } diff --git a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/Interfaces/IHorrorEquipmentService.cs b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/Interfaces/IHorrorEquipmentService.cs index c204d9637..598032870 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/Interfaces/IHorrorEquipmentService.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/Interfaces/IHorrorEquipmentService.cs @@ -1,5 +1,7 @@ +using System.Collections.Generic; using Game.Horror.SaveData; using Game.Shared.Enums; +using Game.Shared.Scriptable.Database.Tables; using Game.Shared.Services.Interfaces; namespace Game.Horror.Services.Interfaces @@ -12,12 +14,28 @@ public interface IHorrorEquipmentService : IGameService /// 指定 (SlotType, Id) が装備可能か判定する。装備対象は Weapon のみで、かつ所持している必要がある。 bool CanEquip(ObjectCategory type, int id); - /// 指定 (SlotType, Id) を装備状態にする。 が成立する場合のみ反映して Dirty にする。 + /// + /// 指定 (SlotType, Id) を装備状態にする。 が成立し、かつマスターを解決できる場合のみ + /// 反映して Dirty にする。マスターを解決できない場合は装備を成立させない(LogError の上 false)。 + /// bool TryEquip(ObjectCategory type, int id); /// 現在装備中の (SlotType, Id) を取得する。未装備または未ロードなら false。 bool TryGetEquipped(out ObjectCategory type, out int id); + /// 装備中武器のマスター(null = 未装備。 前も null)。 + HorrorWeaponMaster EquippedWeaponMaster { get; } + + /// + /// セーブデータの装備記録から装備中武器のマスターを確定する。セーブのロード・新規作成後、 + /// を読む前に呼ぶ。 + /// 解決できない記録は不変条件違反として LogError の上、未装備へ戻す。 + /// + void ResolveEquippedWeaponMaster(); + + /// ショートカット登録+装備中の武器をマスター解決し、同一 Id を重複排除して列挙する(スロット0→3→装備中の順)。 + List GetEquippableWeaponMasters(); + /// 指定スロット(0-3)へアイテム (SlotType, Id) を登録する。 bool TrySetSlot(int index, ObjectCategory slotType, int id); diff --git a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/Interfaces/IHorrorPlayerService.cs b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/Interfaces/IHorrorPlayerService.cs index 31ab5dde9..c9e954b45 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/Interfaces/IHorrorPlayerService.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/Interfaces/IHorrorPlayerService.cs @@ -1,31 +1,33 @@ +using Game.Shared.Scriptable.Database.Tables; using Game.Shared.Services.Interfaces; namespace Game.Horror.Services.Interfaces { /// - /// Horror プレイヤー状態(復帰地点等)を扱うドメインサービスのインターフェース。 + /// Horror プレイヤー状態(操作対象のマスター・HP 等)を扱うドメインサービスのインターフェース。 /// public interface IHorrorPlayerService : IGameService { - /// 最後に使ったセーブポイントの InteractionId(0 = 未記録・未ロード)。 - int LastSavepointId { get; } + /// 現在プレイ中のプレイヤーマスター( 前・解決失敗時は null)。 + HorrorPlayerMaster PlayerMaster { get; } - /// 最後に使ったセーブポイントを記録する。未ロード・Id 0・同値の場合は何もしない。 - void SetLastSavepoint(int interactionId); + /// + /// セーブデータの PlayerId から操作対象のマスターを確定する。プレイヤーの生成に先立って呼ぶ + /// (生成に使うモデルのアドレス自体がマスター由来のため)。 + /// 要求 Id が不在なら既定 Id へフォールバックし、それも不在なら false(マスターデータ側の不備)。 + /// + bool ResolvePlayerMaster(); /// 残 HP(0 = 未記録・未ロード。復元側で最大 HP へ正規化する)。 int CurrentHealth { get; } - /// 残 HP を記録する。未ロード・同値の場合は何もしない。 - void SetCurrentHealth(int health); - - /// 最大 HP(0 = 未設定)。マスタ由来のランタイム値でセーブデータには含まれない。 + /// 最大 HP(0 = マスター未解決)。 由来のランタイム値でセーブデータには含まれない。 int MaxHealth { get; } - /// 最大 HP を記録する。プレイヤー初期化時にマスタ適用後の実効値を共有する。 - void SetMaxHealth(int maxHealth); - - /// HP が満タンで回復アイテムを使用できないか。MaxHealth 未設定(0 以下)は満タン扱いにしない。 + /// HP が満タンで回復アイテムを使用できないか。MaxHealth 未解決(0 以下)は満タン扱いにしない。 bool IsHealthFull { get; } + + /// 残 HP を記録する。未ロード時は LogError の上で何もしない。同値の場合は何もしない。 + void SetCurrentHealth(int health); } } diff --git a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/Interfaces/IHorrorSaveRepository.cs b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/Interfaces/IHorrorSaveRepository.cs index 1265fbb05..6f85f6439 100644 --- a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/Interfaces/IHorrorSaveRepository.cs +++ b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/Services/Interfaces/IHorrorSaveRepository.cs @@ -33,7 +33,7 @@ public interface IHorrorSaveRepository : ISaveRepository /// /// 指定スロットへ保存する。範囲外のスロット番号は保存を行わない。 - /// スロットメタ(スロット番号・保存日時・セーブポイント Id)は保存直前に刻印される。 + /// スロットメタ(スロット番号・保存日時)は保存直前に刻印される。 /// /// 保存先スロット番号(0〜スロット数上限 - 1)。 UniTask SaveBySlotAsync(int slotNo); @@ -43,5 +43,10 @@ public interface IHorrorSaveRepository : ISaveRepository /// /// スロット番号 UniTask DeleteBySlotAsync(int slotNo); + + /// + /// セーブポイントを記録する。未ロード時は LogError の上で何もしない。Id 0・同値の場合も何もしない。 + /// + void SetSavepointId(int interactionId); } } diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Tables/HorrorPlayerMaster.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Tables/HorrorPlayerMaster.cs index 4eef8a186..e38040eae 100644 --- a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Tables/HorrorPlayerMaster.cs +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Tables/HorrorPlayerMaster.cs @@ -14,6 +14,7 @@ public partial class HorrorPlayerMaster [SerializeField] private int _id; // 識別ID [SerializeField] private string _name; // 識別名 + [SerializeField] private string _modelAssetName; // プレイヤーモデルの Addressables アドレス [SerializeField] private float _walkSpeed; // 歩き速度(m/s) [SerializeField] private float _runSpeed; // 走り速度(m/s) @@ -29,14 +30,14 @@ public partial class HorrorPlayerMaster [SerializeField] private float _aimRotationMultiplier; // エイム中のカメラ回転速度倍率 [SerializeField] private float _aimShakeFadeSeconds; // エイム中にカメラ揺れをゼロへ減衰させる秒数(解除時の復帰も同じ秒数) - [SerializeField] private float _bobWalkAmplitude; // ヘッドボブ歩き:縦位置振幅(m) - [SerializeField] private float _bobRunAmplitude; // ヘッドボブ走り:縦位置振幅(m) - [SerializeField] private float _bobWalkSpeed; // ヘッドボブ歩き:位相速度 rad/s(ゆっくり) - [SerializeField] private float _bobRunSpeed; // ヘッドボブ走り:位相速度 rad/s(少しだけ速い) - [SerializeField] private float _bobHorizontalRatio; // ヘッドボブ横位置/縦位置 比 - [SerializeField] private float _bobWalkRoll; // ヘッドボブ歩き:ロール角(度)=知覚される横揺れ - [SerializeField] private float _bobRunRoll; // ヘッドボブ走り:ロール角(度) - [SerializeField] private float _bobAmplitudeResponse; // ヘッドボブ強度イーズの応答 + [SerializeField] private float _headBobWalkAmplitude; // ヘッドボブ歩き:縦位置振幅(m) + [SerializeField] private float _headBobRunAmplitude; // ヘッドボブ走り:縦位置振幅(m) + [SerializeField] private float _headBobWalkSpeed; // ヘッドボブ歩き:位相速度 rad/s(ゆっくり) + [SerializeField] private float _headBobRunSpeed; // ヘッドボブ走り:位相速度 rad/s(少しだけ速い) + [SerializeField] private float _headBobHorizontalRatio; // ヘッドボブ横位置/縦位置 比 + [SerializeField] private float _headBobWalkRoll; // ヘッドボブ歩き:ロール角(度)=知覚される横揺れ + [SerializeField] private float _headBobRunRoll; // ヘッドボブ走り:ロール角(度) + [SerializeField] private float _headBobAmplitudeResponse; // ヘッドボブ強度イーズの応答 [SerializeField] private float _idleSwaySpeed; // アイドルスウェイ:位相速度 rad/s(呼吸 ~5秒周期) [SerializeField] private float _idleSwayAmplitude; // アイドルスウェイ:縦位置振幅(m, ヘッドボブより小) @@ -68,6 +69,12 @@ public string Name set => _name = value; } + public string ModelAssetName + { + get => _modelAssetName; + set => _modelAssetName = value; + } + public float WalkSpeed { get => _walkSpeed; @@ -134,52 +141,52 @@ public float AimShakeFadeSeconds set => _aimShakeFadeSeconds = value; } - public float BobWalkAmplitude + public float HeadBobWalkAmplitude { - get => _bobWalkAmplitude; - set => _bobWalkAmplitude = value; + get => _headBobWalkAmplitude; + set => _headBobWalkAmplitude = value; } - public float BobRunAmplitude + public float HeadBobRunAmplitude { - get => _bobRunAmplitude; - set => _bobRunAmplitude = value; + get => _headBobRunAmplitude; + set => _headBobRunAmplitude = value; } - public float BobWalkSpeed + public float HeadBobWalkSpeed { - get => _bobWalkSpeed; - set => _bobWalkSpeed = value; + get => _headBobWalkSpeed; + set => _headBobWalkSpeed = value; } - public float BobRunSpeed + public float HeadBobRunSpeed { - get => _bobRunSpeed; - set => _bobRunSpeed = value; + get => _headBobRunSpeed; + set => _headBobRunSpeed = value; } - public float BobHorizontalRatio + public float HeadBobHorizontalRatio { - get => _bobHorizontalRatio; - set => _bobHorizontalRatio = value; + get => _headBobHorizontalRatio; + set => _headBobHorizontalRatio = value; } - public float BobWalkRoll + public float HeadBobWalkRoll { - get => _bobWalkRoll; - set => _bobWalkRoll = value; + get => _headBobWalkRoll; + set => _headBobWalkRoll = value; } - public float BobRunRoll + public float HeadBobRunRoll { - get => _bobRunRoll; - set => _bobRunRoll = value; + get => _headBobRunRoll; + set => _headBobRunRoll = value; } - public float BobAmplitudeResponse + public float HeadBobAmplitudeResponse { - get => _bobAmplitudeResponse; - set => _bobAmplitudeResponse = value; + get => _headBobAmplitudeResponse; + set => _headBobAmplitudeResponse = value; } public float IdleSwaySpeed diff --git a/src/Game.Client/Assets/ProjectAssets/Horror/HorrorPlayer.prefab b/src/Game.Client/Assets/ProjectAssets/Horror/HorrorPlayer.prefab index 4c27015f0..645d0108e 100644 --- a/src/Game.Client/Assets/ProjectAssets/Horror/HorrorPlayer.prefab +++ b/src/Game.Client/Assets/ProjectAssets/Horror/HorrorPlayer.prefab @@ -2255,7 +2255,6 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 95314a647cf9fa44799cf53ff897374d, type: 3} m_Name: m_EditorClassIdentifier: Game.MVC.Horror::Game.Horror.Player.HorrorPlayerController - _playerId: 1 _mainCamera: {fileID: 6031481874108313397} _walkSpeed: 2 _runSpeed: 5 diff --git a/src/Game.Client/Assets/ProjectAssets/Scriptable/Database/Tables/HorrorPlayerMasterTable.asset b/src/Game.Client/Assets/ProjectAssets/Scriptable/Database/Tables/HorrorPlayerMasterTable.asset index 3740affad..8e0a38816 100644 --- a/src/Game.Client/Assets/ProjectAssets/Scriptable/Database/Tables/HorrorPlayerMasterTable.asset +++ b/src/Game.Client/Assets/ProjectAssets/Scriptable/Database/Tables/HorrorPlayerMasterTable.asset @@ -15,6 +15,7 @@ MonoBehaviour: records: - _id: 1 _name: Default + _modelAssetName: HorrorPlayer _walkSpeed: 2 _runSpeed: 5 _jump: 5 @@ -26,14 +27,14 @@ MonoBehaviour: _aimTransitionSpeed: 8 _aimRotationMultiplier: 0.4 _aimShakeFadeSeconds: 1 - _bobWalkAmplitude: 0.04 - _bobRunAmplitude: 0.06 - _bobWalkSpeed: 10 - _bobRunSpeed: 15 - _bobHorizontalRatio: 0.5 - _bobWalkRoll: 0.05 - _bobRunRoll: 0.1 - _bobAmplitudeResponse: 10 + _headBobWalkAmplitude: 0.04 + _headBobRunAmplitude: 0.06 + _headBobWalkSpeed: 10 + _headBobRunSpeed: 15 + _headBobHorizontalRatio: 0.5 + _headBobWalkRoll: 0.05 + _headBobRunRoll: 0.1 + _headBobAmplitudeResponse: 10 _idleSwaySpeed: 1.2 _idleSwayAmplitude: 0.05 _idleSwayRoll: 0.01