diff --git a/Assets/Editor/LocalizationSmoke.cs b/Assets/Editor/LocalizationSmoke.cs index 6f03e47..38c3c0c 100644 --- a/Assets/Editor/LocalizationSmoke.cs +++ b/Assets/Editor/LocalizationSmoke.cs @@ -23,7 +23,7 @@ namespace Ironspore.Editor static int[] initialEquipment; static float nextAt; static VisualElement Root => game.Hud.GetComponent().rootVisualElement; - static string MainPath => Path.Combine(Application.persistentDataPath, "Ironspore", "profile.json"); + static string MainPath => ProfileStore.SavePath(false); static byte[] mainBytes; [Serializable] @@ -97,7 +97,7 @@ namespace Ironspore.Editor initialChassis = game.SelectedChassis; initialEquipment = (int[])game.InitialEquipment.Clone(); rangeFiles.Clear(); - string range = Path.Combine(Application.persistentDataPath, "Ironspore", "range.json"); + string range = ProfileStore.SavePath(true); foreach (string path in new[] { range, range + ".bak", range + ".tmp" }) rangeFiles[path] = File.Exists(path) ? File.ReadAllBytes(path) : null; Directory.CreateDirectory(Folder + "/Screenshots"); diff --git a/Assets/Game/Localization/Tables/en.json b/Assets/Game/Localization/Tables/en.json index b60691f..e620342 100644 --- a/Assets/Game/Localization/Tables/en.json +++ b/Assets/Game/Localization/Tables/en.json @@ -46,7 +46,7 @@ }, { "key": "ui.home.locked", - "value": "Selected advanced part needs schematic research. Try any loadout in the driving range." + "value": "Selected gear is out of stock. Standard slots can use loaners. All models are available in the range." }, { "key": "ui.home.moveHint", @@ -158,7 +158,7 @@ }, { "key": "ui.hud.telemetry", - "value": "{0}\n{1} / {2}\nCARGO {3} / 4\n{4}" + "value": "{0}\n{1} / {2}\nCARGO {3} / 16\n{4}" }, { "key": "ui.hud.transportReady", @@ -174,15 +174,15 @@ }, { "key": "ui.cargo.title", - "value": "FIELD CARGO" + "value": "Cargo and ejection safe case" }, { "key": "ui.cargo.used", - "value": "POD ALREADY USED / remaining cargo must leave with your mech." + "value": "Pod launched. On destruction, only items in the safe case return." }, { "key": "ui.cargo.help", - "value": "Select up to two stacks. Close this panel, stand still and hold G for 3 seconds to send the pod." + "value": "Drag an item or select it and click a cell. Press R to rotate. The safe case returns on destruction. Mark cargo, close this panel and hold G while stationary for three seconds to launch the pod." }, { "key": "ui.cargo.stack", @@ -194,7 +194,7 @@ }, { "key": "ui.cargo.capacity", - "value": "HOLD {0} / 4 POD {1} / 2" + "value": "Hold {0}/16 cells Pod {1}/4 cells" }, { "key": "ui.return", @@ -290,7 +290,7 @@ }, { "key": "report.destroyed", - "value": "MECH LOST / {0} carried stack(s) lost. Previously shipped cargo remains in the hangar." + "value": "Mech destroyed. Lost {0} equipped items or cargo stacks. The safe case returned {1} items or stacks." }, { "key": "report.complete", @@ -298,7 +298,7 @@ }, { "key": "report.extracted", - "value": "EARLY EXTRACTION / all carried materials and schematics secured." + "value": "Extracted. Equipped gear, cargo and safe case contents recovered." }, { "key": "notice.unfinished", @@ -310,7 +310,7 @@ }, { "key": "notice.researchFirst", - "value": "Research the selected equipment schematic first, or try it in the driving range." + "value": "Selected gear is out of stock." }, { "key": "notice.saveFailed", @@ -334,7 +334,7 @@ }, { "key": "notice.twoSlots", - "value": "The cargo pod has two slots." + "value": "The marked items do not fit in the 2×2 pod." }, { "key": "notice.holdFull", @@ -535,6 +535,54 @@ { "key": "item.blueprint_reactive_guard", "value": "SCHEMATIC / REACTIVE SHIELD" + }, + { + "key": "ui.cargo.hold", + "value": "Cargo hold · 4×4" + }, + { + "key": "ui.cargo.case", + "value": "Ejection safe case · 2×2" + }, + { + "key": "ui.cargo.selection", + "value": "Selected {0} {1}×{2} cells Quantity {3}" + }, + { + "key": "ui.cargo.rotate", + "value": "Rotate (R)" + }, + { + "key": "ui.cargo.cancel", + "value": "Deselect" + }, + { + "key": "ui.cargo.install", + "value": "Install" + }, + { + "key": "ui.cargo.mark", + "value": "Mark for pod" + }, + { + "key": "ui.cargo.unmark", + "value": "Unmark" + }, + { + "key": "notice.installFailed", + "value": "Cannot install. Check the loadout budget and the space for the replaced part." + }, + { + "key": "notice.placement", + "value": "Cannot place here. Check bounds, occupied cells and stack quantity." + }, + { + "key": "ui.home.chooseItem", + "value": "Equip {0} · {1}" + }, + { + "key": "ui.home.manufacture", + "value": "Build {0} · {1} alloy / {2} cells" } ] } diff --git a/Assets/Game/Localization/Tables/zh-Hans.json b/Assets/Game/Localization/Tables/zh-Hans.json index 861fd8b..a687f57 100644 --- a/Assets/Game/Localization/Tables/zh-Hans.json +++ b/Assets/Game/Localization/Tables/zh-Hans.json @@ -46,7 +46,7 @@ }, { "key": "ui.home.locked", - "value": "所选高级部件尚未研究蓝图。可在驾驶试验场试用任意配装。" + "value": "所选装备库存不足。基础槽位可借用救济装,试验场可试用所有型号。" }, { "key": "ui.home.moveHint", @@ -158,7 +158,7 @@ }, { "key": "ui.hud.telemetry", - "value": "{0}\n{1} / {2}\n货仓 {3} / 4\n{4}" + "value": "{0}\n{1} / {2}\n货仓 {3} / 16\n{4}" }, { "key": "ui.hud.transportReady", @@ -174,15 +174,15 @@ }, { "key": "ui.cargo.title", - "value": "随身货仓" + "value": "货仓与弹射式安全箱" }, { "key": "ui.cargo.used", - "value": "回收舱已使用 / 剩余货物需要随整机撤离。" + "value": "主动货舱已发射。机毁时只有弹射式安全箱中的物品返航。" }, { "key": "ui.cargo.help", - "value": "最多选择两组货物。关闭面板后原地按住 G 3 秒,发射回收舱。" + "value": "拖动物品或选中后点击目标格,按 R 旋转。安全箱在机毁时自动返航。标记货物后关闭面板,原地按住 G 三秒发射主动货舱。" }, { "key": "ui.cargo.stack", @@ -194,7 +194,7 @@ }, { "key": "ui.cargo.capacity", - "value": "随身货仓 {0} / 4 回收舱 {1} / 2" + "value": "普通货仓 {0}/16 格 主动货舱 {1}/4 格" }, { "key": "ui.return", @@ -290,7 +290,7 @@ }, { "key": "report.destroyed", - "value": "机甲损失 / 丢失 {0} 组随身货物。已送回的货物仍保留在机库。" + "value": "机体损毁。损失 {0} 件装备或货物,弹射式安全箱送回 {1} 件物品。" }, { "key": "report.complete", @@ -298,7 +298,7 @@ }, { "key": "report.extracted", - "value": "提前撤离 / 随身材料与蓝图已全部入库。" + "value": "撤离成功。已装装备、普通货仓和安全箱物品已回收。" }, { "key": "notice.unfinished", @@ -310,7 +310,7 @@ }, { "key": "notice.researchFirst", - "value": "请先研究所选装备蓝图,或在驾驶试验场试用。" + "value": "机库中没有所选装备。" }, { "key": "notice.saveFailed", @@ -334,7 +334,7 @@ }, { "key": "notice.twoSlots", - "value": "回收舱最多容纳两组货物。" + "value": "所选物品放不进 2×2 主动货舱。" }, { "key": "notice.holdFull", @@ -535,6 +535,54 @@ { "key": "item.blueprint_reactive_guard", "value": "蓝图 / 反应护盾" + }, + { + "key": "ui.cargo.hold", + "value": "普通货仓 · 4×4" + }, + { + "key": "ui.cargo.case", + "value": "弹射式安全箱 · 2×2" + }, + { + "key": "ui.cargo.selection", + "value": "已选 {0} {1}×{2} 格 移动数量 {3}" + }, + { + "key": "ui.cargo.rotate", + "value": "旋转 (R)" + }, + { + "key": "ui.cargo.cancel", + "value": "取消选择" + }, + { + "key": "ui.cargo.install", + "value": "安装" + }, + { + "key": "ui.cargo.mark", + "value": "标记发射" + }, + { + "key": "ui.cargo.unmark", + "value": "取消标记" + }, + { + "key": "notice.installFailed", + "value": "无法安装。请检查机体预算和旧件所需的空间。" + }, + { + "key": "notice.placement", + "value": "无法放置。请检查边界、占用和堆叠数量。" + }, + { + "key": "ui.home.chooseItem", + "value": "选用 {0} · {1}" + }, + { + "key": "ui.home.manufacture", + "value": "制造 {0} · 合金 {1} / 电芯 {2}" } ] } diff --git a/Assets/Game/Scripts/Equipment/InventoryGrid.cs b/Assets/Game/Scripts/Equipment/InventoryGrid.cs new file mode 100644 index 0000000..9f8e04f --- /dev/null +++ b/Assets/Game/Scripts/Equipment/InventoryGrid.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; + +namespace Ironspore +{ + public enum Hold { Cargo, SafeCase } + + // Stable content IDs also resolve the existing four gameplay slot indices. + public static class InventoryGrid + { + public const int CargoSize = 4, CaseSize = 2, PodSize = 2; + static readonly string[][] Slots = { + new[] { "rapid", "coil", "siege" }, new[] { "guided", "pulse" }, + new[] { "efficient", "vector" }, new[] { "composite_guard", "reactive_guard" } + }; + + public static string EquipmentId(int slot, int index) => + slot >= 0 && slot < Slots.Length && index >= 0 && index < Slots[slot].Length ? Slots[slot][index] : null; + + public static int Slot(string id) => Array.FindIndex(Slots, group => Array.IndexOf(group, id) >= 0); + public static int Index(int slot, string id) => Array.IndexOf(Slots[slot], id); + public static bool IsMaterial(string id) => id == "alloy" || id == "cells"; + public static bool IsBlueprint(string id) => id != null && id.StartsWith("blueprint_", StringComparison.Ordinal) && id.Length > 10; + public static bool Known(string id) => IsMaterial(id) || IsBlueprint(id) || Slot(id) >= 0; + + public static (string item, int alloy, int cells) Recipe(string blueprint) + { + switch (blueprint) + { + case "blueprint_coil_capacitor": return ("coil", 4, 2); + case "blueprint_boost_efficient": return ("vector", 3, 2); + case "blueprint_reactive_guard": return ("reactive_guard", 4, 1); + default: return (null, 0, 0); + } + } + + public static void Size(string id, bool rotated, out int width, out int height) + { + width = 1; + height = 1; + switch (id) + { + case "rapid": height = 3; break; + case "coil": width = 2; height = 3; break; + case "siege": width = 2; height = 4; break; + case "pulse": case "reactive_guard": case "vector": width = 2; height = 2; break; + case "guided": case "efficient": case "composite_guard": height = 2; break; + } + if (rotated) (width, height) = (height, width); + } + + public static int Area(CargoStack item) + { + Size(item.itemId, false, out int w, out int h); + return w * h; + } + + public static bool Fits(List items, int size, CargoStack item, int x, int y, bool rotated) + { + Size(item.itemId, rotated, out int w, out int h); + if (x < 0 || y < 0 || x > size - w || y > size - h) return false; + foreach (var other in items) + { + if (other == null) return false; + if (ReferenceEquals(item, other)) continue; + Size(other.itemId, other.rotated, out int ow, out int oh); + if (x < other.x + ow && x + w > other.x && y < other.y + oh && y + h > other.y) + return false; + } + return true; + } + + public static bool Place(List items, int size, CargoStack item, int x, int y, bool rotated) + { + if (items.Contains(item) || !Fits(items, size, item, x, y, rotated)) return false; + item.x = x; + item.y = y; + item.rotated = rotated; + items.Add(item); + return true; + } + + public static bool AutoPlace(List items, int size, CargoStack item) + { + for (int turn = 0; turn < 2; turn++) + for (int y = 0; y < size; y++) + for (int x = 0; x < size; x++) + if (Place(items, size, item, x, y, turn == 1)) return true; + return false; + } + + // The pod is small. Try alternate placements so a greedy choice cannot reject a valid manifest. + public static bool PodFits(List selection) + { + if (selection.Count == 0 || selection.Count > PodSize * PodSize) return false; + return Pack(0, new List()); + bool Pack(int index, List placed) + { + if (index == selection.Count) return true; + var next = new CargoStack(selection[index].itemId, selection[index].count) { uid = selection[index].uid }; + for (int turn = 0; turn < 2; turn++) + for (int y = 0; y < PodSize; y++) + for (int x = 0; x < PodSize; x++) + { + if (!Place(placed, PodSize, next, x, y, turn == 1)) continue; + if (Pack(index + 1, placed)) return true; + placed.RemoveAt(placed.Count - 1); + } + return false; + } + } + } +} diff --git a/Assets/Game/Scripts/Equipment/InventoryGrid.cs.meta b/Assets/Game/Scripts/Equipment/InventoryGrid.cs.meta new file mode 100644 index 0000000..63c7c9b --- /dev/null +++ b/Assets/Game/Scripts/Equipment/InventoryGrid.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2e9cb0228d5ccb643a654d0e82090d2a \ No newline at end of file diff --git a/Assets/Game/Scripts/Localization/GameLocalization.cs b/Assets/Game/Scripts/Localization/GameLocalization.cs index 1c5605c..6238cf2 100644 --- a/Assets/Game/Scripts/Localization/GameLocalization.cs +++ b/Assets/Game/Scripts/Localization/GameLocalization.cs @@ -7,7 +7,11 @@ namespace Ironspore { public static class GameLocalization { +#if UNITY_EDITOR + public static string PreferenceKey => "settings.locale." + Hash128.Compute(Application.dataPath); +#else public const string PreferenceKey = "settings.locale"; +#endif public const string FallbackCode = "en"; public static event Action Changed; public static string CurrentCode { get; private set; } = FallbackCode; @@ -40,6 +44,7 @@ namespace Ironspore tables.Add(locale.code, ReadTable(locale.table)); if (!tables.ContainsKey(FallbackCode)) throw new InvalidOperationException("English fallback table is missing."); + if (catalog.uiFont) PrepareGlyphs(catalog); string system = CultureInfo.CurrentUICulture.Name; if (Application.systemLanguage == SystemLanguage.ChineseSimplified || Application.systemLanguage == SystemLanguage.ChineseTraditional || Application.systemLanguage == SystemLanguage.Chinese) system = "zh-Hans"; @@ -48,6 +53,23 @@ namespace Ironspore CurrentCode = FallbackCode; } + static void PrepareGlyphs(LocalizationCatalog catalog) + { + // A clean import can start with an empty dynamic atlas. + var missingGlyphs = new SortedSet(); + foreach (var table in tables.Values) + foreach (string value in table.Values) + foreach (char glyph in value) + if (!char.IsControl(glyph) && !catalog.uiFont.HasCharacter(glyph)) missingGlyphs.Add(glyph); + if (missingGlyphs.Count > 0) + { + var glyphs = new char[missingGlyphs.Count]; + missingGlyphs.CopyTo(glyphs); + catalog.uiFont.TryAddCharacters(new string(glyphs), out string absent); + if (!string.IsNullOrEmpty(absent)) Debug.LogWarning("Missing UI font glyphs: " + absent); + } + } + public static Dictionary ReadTable(TextAsset source) { if (!source) diff --git a/Assets/Game/Scripts/Mechs/MechPilot.cs b/Assets/Game/Scripts/Mechs/MechPilot.cs index 2b49b8f..d5381fd 100644 --- a/Assets/Game/Scripts/Mechs/MechPilot.cs +++ b/Assets/Game/Scripts/Mechs/MechPilot.cs @@ -92,12 +92,12 @@ namespace Ironspore pitch = Mathf.Clamp(pitch - look.y * .105f, -72, 75); if (game.Action("View").WasPressedThisFrame()) FirstPerson = !FirstPerson; - if (game.Action("Guard").WasPressedThisFrame() && Energy >= 20) + if (game.Action("Guard").WasPressedThisFrame() && game.Run.equipment[3] >= 0 && Energy >= 20) { Energy -= 20; Guard = 2.5f; } - if (game.Action("Boost").WasPressedThisFrame() && boostCooldown <= 0 && Energy >= 18) + if (game.Action("Boost").WasPressedThisFrame() && game.Run.equipment[2] >= 0 && boostCooldown <= 0 && Energy >= 18) { boostDirection = Quaternion.Euler(0, yaw, 0) * new Vector3(movement.x, 0, movement.y == 0 && movement.x == 0 ? 1 : movement.y); boostDirection.Normalize(); @@ -116,7 +116,7 @@ namespace Ironspore } } Vector3 velocity = Quaternion.Euler(0, yaw, 0) * new Vector3(movement.x, 0, movement.y); - float speed = frame.speed * game.Part(2, game.Run.equipment[2]).speedFactor; + float speed = frame.speed * (game.Part(2, game.Run.equipment[2])?.speedFactor ?? 1f); velocity = Vector3.ClampMagnitude(velocity, 1) * speed; if (boostRemaining > 0) { @@ -141,7 +141,7 @@ namespace Ironspore freshTrigger = false; if (!freshTrigger && game.Action("Fire").IsPressed() && shotCooldown <= 0) Shoot(false); - if (game.Action("Secondary").WasPressedThisFrame() && sideCooldown <= 0 && Energy >= 15) + if (game.Action("Secondary").WasPressedThisFrame() && game.Run.equipment[1] >= 0 && sideCooldown <= 0 && Energy >= 15) { Energy -= 15; Shoot(true); @@ -171,6 +171,7 @@ namespace Ironspore void Shoot(bool secondary) { var part = game.Part(secondary ? 1 : 0, game.Run.equipment[secondary ? 1 : 0]); + if (!part) return; shotCooldown = secondary ? shotCooldown : 1 / part.fireRate; Ray ray = view.ViewportPointToRay(new Vector3(.5f, .5f)); Vector3 target = ray.GetPoint(180); @@ -211,7 +212,7 @@ namespace Ironspore if (!game.InField || Health <= 0) return; LastDamageFrame = Time.frameCount; - float armorFactor = game.Part(3, game.Run.equipment[3]).id == "reactive_guard" ? .72f : 1; + float armorFactor = game.Part(3, game.Run.equipment[3])?.id == "reactive_guard" ? .72f : 1; Health = Mathf.Max(0, Health - amount * armorFactor * (Guard > 0 ? .2f : 1)); game.Run.armor = Health; } diff --git a/Assets/Game/Scripts/Runs/GameSession.cs b/Assets/Game/Scripts/Runs/GameSession.cs index b9a6394..eb7177d 100644 --- a/Assets/Game/Scripts/Runs/GameSession.cs +++ b/Assets/Game/Scripts/Runs/GameSession.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; using UnityEngine; using UnityEngine.InputSystem; @@ -53,6 +52,7 @@ namespace Ironspore get; private set; } public int SelectedChassis = 1; + public readonly string[] SelectedInstances = new string[4]; public int[] InitialEquipment = { 0, 0, 0, 0 }; LocalizedMessage noticeMessage; public string Notice => noticeMessage?.ToString() ?? ""; @@ -76,7 +76,7 @@ namespace Ironspore Active = this; GameLocalization.Initialize(localization); Application.targetFrameRate = 120; - mainStore = new ProfileStore(Path.Combine(Application.persistentDataPath, "Ironspore", "profile.json")); + mainStore = new ProfileStore(ProfileStore.SavePath(false)); Store = mainStore; Input = Instantiate(inputTemplate); Input.Enable(); @@ -90,14 +90,16 @@ namespace Ironspore World.BuildHangar(); Hud.ShowHangar(); SetMenu(true); + if (Run.active && (Run.destructionPending || Run.armor <= 0)) + EndSortie(true); if (startInRange) StartTrial(); } public EquipmentDefinition Part(int slot, int index) { - var list = Array.FindAll(equipment, x => x.slot == slot); - return list[Mathf.Clamp(index, 0, list.Length - 1)]; + string id = InventoryGrid.EquipmentId(slot, index); + return Array.Find(equipment, x => x.id == id); } public int PartCount(int slot) => Array.FindAll(equipment, x => x.slot == slot).Length; public bool Fits(int frame, int[] parts) @@ -106,6 +108,8 @@ namespace Ironspore for (int i = 0; i < 4; i++) { var p = Part(i, parts[i]); + if (parts[i] == -1) continue; + if (!p) return false; mass += p.mass; power += p.power; } @@ -113,10 +117,13 @@ namespace Ironspore } public bool StartingPartsUnlocked() { - var unlocked = mainStore.Data.unlocked; - return (InitialEquipment[0] != 1 || unlocked.Contains("blueprint_coil_capacitor")) - && (InitialEquipment[2] != 1 || unlocked.Contains("blueprint_boost_efficient")) - && (InitialEquipment[3] != 1 || unlocked.Contains("blueprint_reactive_guard")); + for (int slot = 0; slot < 4; slot++) + { + if (InitialEquipment[slot] == 0 && SelectedInstances[slot] == null) continue; + string id = InventoryGrid.EquipmentId(slot, InitialEquipment[slot]); + if (!mainStore.Data.warehouse.Exists(i => i.itemId == id && (SelectedInstances[slot] == null || i.uid == SelectedInstances[slot]))) return false; + } + return true; } public void BeginSortie() @@ -138,7 +145,7 @@ namespace Ironspore } Trial = false; Store = mainStore; - if (!Store.Begin(SelectedChassis, InitialEquipment, chassis[SelectedChassis].armor, Environment.TickCount)) + if (!Store.Begin(SelectedChassis, InitialEquipment, chassis[SelectedChassis].armor, Environment.TickCount, selected: SelectedInstances)) { NotifySaveFailure(); return; @@ -147,17 +154,23 @@ namespace Ironspore } public void StartTrial() { + var range = new ProfileStore(ProfileStore.SavePath(true)); + if ((range.Data.run.active && !range.Finish(true)) || + !range.Begin(SelectedChassis, InitialEquipment, chassis[SelectedChassis].armor, 1739, trial: true)) + { + Notify("notice.saveFailed"); + return; + } Trial = true; - Store = new ProfileStore(Path.Combine(Application.persistentDataPath, "Ironspore", "range.json")); - Store.Begin(SelectedChassis, InitialEquipment, chassis[SelectedChassis].armor, 1739); + Store = range; EnterField(); } public void Resume() { Trial = false; Store = mainStore; - if (Run.active) - EnterField(); + if (Run.active && (Run.destructionPending || Run.armor <= 0)) EndSortie(true); + else if (Run.active) EnterField(); } void EnterField() { @@ -211,6 +224,11 @@ namespace Ironspore noticeMessage = null; if (!InField || !Pilot || ending) return; + if (Pilot.Health <= 0 || Run.destructionPending) + { + EndSortie(true); + return; + } if (Action("Pause").WasPressedThisFrame()) { if (MenuOpen) @@ -239,11 +257,6 @@ namespace Ironspore if (!MenuOpen && Action("Interact").WasPressedThisFrame() && World.Focused) World.Focused.Activate(); TickCargo(); - if (Pilot.Health <= 0) - { - EndSortie(true); - return; - } autosaveTimer += Time.deltaTime; if (autosaveTimer >= 10) { @@ -281,10 +294,12 @@ namespace Ironspore { if (SelectedCargo.Contains(uid)) SelectedCargo.Remove(uid); - else if (SelectedCargo.Count < 2) - SelectedCargo.Add(uid); else - Notify("notice.twoSlots"); + { + var next = new List(SelectedCargo) { uid }; + if (Store.CanSendCargo(next)) SelectedCargo.Add(uid); + else Notify("notice.twoSlots"); + } Hud.ShowCargo(); } public void SnapshotVitals() @@ -296,7 +311,9 @@ namespace Ironspore } public void Search(WorldInteractable target) { - if (!Store.AddCargo(target.itemId, target.amount, target.key)) + int index = target.offersPart ? Mathf.Clamp(target.partIndex, 0, PartCount(target.partSlot) - 1) : -1; + string offered = target.offersPart ? Part(target.partSlot, index).id : null; + if (!Store.AddCargo(target.itemId, target.amount, target.key, offered)) { NotifySaveFailure("notice.holdFull"); return; @@ -308,22 +325,27 @@ namespace Ironspore if (target.offersPart) { SetMenu(true); - Hud.ShowPart(target.partSlot, target.partIndex); + Hud.ShowPart(target.partSlot, index); } } public void Install(int slot, int index) { - var next = (int[])Run.equipment.Clone(); - next[slot] = index; - if (!Fits(Run.chassis, next)) - { - Notify("notice.partBudget"); - return; - } - Store.Commit(s => { s.run.equipment = next; return true; }); - Pilot.ApplyLoadout(); + var item = Run.cargo.FindLast(i => i.itemId == InventoryGrid.EquipmentId(slot, index)); + if (item == null || !InstallItem(item, Hold.Cargo)) return; CloseMenu(); - Notify("notice.refit", GameLocalization.Message("equipment." + Part(slot, index).id + ".name")); + } + + public bool InstallItem(CargoStack item, Hold from) + { + if (!Store.InstallItem(item.uid, from, parts => Fits(Run.chassis, parts))) + { + NotifySaveFailure("notice.installFailed"); + return false; + } + SelectedCargo.Remove(item.uid); + Pilot.ApplyLoadout(); + Notify("notice.refit", GameLocalization.Message("equipment." + item.itemId + ".name")); + return true; } public void CompleteObjective() { @@ -398,15 +420,19 @@ namespace Ironspore return; SnapshotVitals(); bool complete = Run.routeCompleted; - int lost = Run.cargo.Count; + int lost = Run.cargo.Count + Run.installed.Count; + int protectedCount = Run.safeCase.FindAll(i => !i.borrowed).Count; if (!Store.Finish(destroyed)) { NotifySaveFailure(); return; } ending = true; - reportMessage = GameLocalization.Message(Trial ? "report.range" : destroyed ? "report.destroyed" : complete ? "report.complete" : "report.extracted", lost); + reportMessage = GameLocalization.Message(Trial ? "report.range" : destroyed ? "report.destroyed" : complete ? "report.complete" : "report.extracted", lost, protectedCount); InField = false; + Paused = false; + Time.timeScale = 1; + Array.Clear(SelectedInstances, 0, SelectedInstances.Length); Trial = false; Store = mainStore; Pilot = null; diff --git a/Assets/Game/Scripts/Save/ProfileInventory.cs b/Assets/Game/Scripts/Save/ProfileInventory.cs new file mode 100644 index 0000000..0d772e9 --- /dev/null +++ b/Assets/Game/Scripts/Save/ProfileInventory.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; + +namespace Ironspore +{ + public sealed partial class ProfileStore + { + static bool Live(RunRecord run) => run.active && !run.destructionPending && run.armor > 0; + static List Items(RunRecord r, Hold hold) => hold == Hold.Cargo ? r.cargo : r.safeCase; + static int Capacity(Hold hold) => hold == Hold.Cargo ? InventoryGrid.CargoSize : InventoryGrid.CaseSize; + static bool ValidHold(Hold hold) => hold == Hold.Cargo || hold == Hold.SafeCase; + + static bool Valid(GameSave s) + { + if (s?.run == null || s.receipts == null || s.blueprints == null || s.unlocked == null || + s.warehouse == null || s.run.cargo == null || s.run.safeCase == null || s.run.installed == null || + s.run.searched == null || s.run.equipment?.Length != 4 || s.version != GameSave.CurrentVersion) return false; + var ids = new HashSet(); + var slots = new HashSet(); + bool Check(CargoStack i) => i != null && !string.IsNullOrEmpty(i.uid) && ids.Add(i.uid) && + InventoryGrid.Known(i.itemId) && i.count > 0 && i.count <= (InventoryGrid.IsMaterial(i.itemId) ? 10 : 1); + foreach (var i in s.warehouse) + if (!Check(i) || i.borrowed || InventoryGrid.Slot(i.itemId) < 0) return false; + foreach (var hold in new[] { Hold.Cargo, Hold.SafeCase }) + { + var list = Items(s.run, hold); + foreach (var i in list) + if (!Check(i) || !InventoryGrid.Fits(list, Capacity(hold), i, i.x, i.y, i.rotated)) return false; + } + foreach (var i in s.run.installed) + { + if (!Check(i)) return false; + int slot = InventoryGrid.Slot(i.itemId); + if (slot < 0 || !slots.Add(slot) || InventoryGrid.EquipmentId(slot, s.run.equipment[slot]) != i.itemId) return false; + } + if (s.run.active) + { + if (string.IsNullOrEmpty(s.run.id)) return false; + for (int slot = 0; slot < 4; slot++) + if (!slots.Contains(slot) && s.run.equipment[slot] != -1) return false; + } + else if (s.run.destructionPending || s.run.installed.Count + s.run.cargo.Count + s.run.safeCase.Count != 0) return false; + return new HashSet(s.receipts).Count == s.receipts.Count; + } + + public bool Manufacture(string blueprint) => Commit(s => + { + var recipe = InventoryGrid.Recipe(blueprint); + if (s.run.active || recipe.item == null || !s.unlocked.Contains(blueprint) || + s.alloy < recipe.alloy || s.cells < recipe.cells) return false; + s.alloy -= recipe.alloy; + s.cells -= recipe.cells; + s.warehouse.Add(new CargoStack(recipe.item, 1)); + return true; + }); + + public bool AddCargo(string id, int count, string sourceKey = "", string offeredPart = null) + { + if (!InventoryGrid.Known(id) || count < 1 || count > (InventoryGrid.IsMaterial(id) ? 10 : 1)) return false; + return Commit(s => + { + var r = s.run; + if (!Live(r) || (!string.IsNullOrEmpty(sourceKey) && r.searched.Contains(sourceKey))) return false; + int remaining = count; + if (InventoryGrid.IsMaterial(id)) + { + foreach (var stack in r.cargo) + { + if (stack.itemId != id) continue; + int added = Math.Min(10 - stack.count, remaining); + stack.count += added; + remaining -= added; + if (remaining == 0) break; + } + } + if (remaining > 0 && !InventoryGrid.AutoPlace(r.cargo, InventoryGrid.CargoSize, new CargoStack(id, remaining))) return false; + if (offeredPart != null && (InventoryGrid.Slot(offeredPart) < 0 || + !InventoryGrid.AutoPlace(r.cargo, InventoryGrid.CargoSize, new CargoStack(offeredPart, 1)))) return false; + if (!string.IsNullOrEmpty(sourceKey)) r.searched.Add(sourceKey); + return true; + }); + } + + // Omitting coordinates requests first-fit placement. Explicit placement also rotates in place. + public bool MoveItem(string uid, Hold from, Hold to, int x = -1, int y = -1, bool rotated = false, int quantity = 0) + { + if (!ValidHold(from) || !ValidHold(to)) return false; + return Commit(s => + { + if (!Live(s.run)) return false; + var source = Items(s.run, from); + var item = source.Find(i => i.uid == uid); + if (item == null || quantity < 0 || quantity > item.count) return false; + if (quantity == 0 || quantity == item.count) source.Remove(item); + else + { + item.count -= quantity; + item = new CargoStack(item.itemId, quantity); + } + var target = Items(s.run, to); + return x == -1 && y == -1 ? InventoryGrid.AutoPlace(target, Capacity(to), item) + : InventoryGrid.Place(target, Capacity(to), item, x, y, rotated); + }); + } + + public bool SwapItems(string uid, Hold from, string otherUid, Hold to, bool rotated) + { + if (!ValidHold(from) || !ValidHold(to) || uid == otherUid) return false; + return Commit(s => + { + if (!Live(s.run)) return false; + var source = Items(s.run, from); + var target = Items(s.run, to); + var first = source.Find(i => i.uid == uid); + var second = target.Find(i => i.uid == otherUid); + if (first == null || second == null) return false; + int x = first.x, y = first.y; + source.Remove(first); + target.Remove(second); + return InventoryGrid.Place(target, Capacity(to), first, second.x, second.y, rotated) && + InventoryGrid.Place(source, Capacity(from), second, x, y, second.rotated); + }); + } + + public bool MergeMaterial(string sourceUid, Hold from, string targetUid, Hold to, int quantity) + { + if (!ValidHold(from) || !ValidHold(to) || sourceUid == targetUid || quantity < 1) return false; + return Commit(s => + { + if (!Live(s.run)) return false; + var source = Items(s.run, from).Find(i => i.uid == sourceUid); + var target = Items(s.run, to).Find(i => i.uid == targetUid); + if (source == null || target == null || source.itemId != target.itemId || !InventoryGrid.IsMaterial(source.itemId) + || quantity > source.count || quantity > 10 - target.count) return false; + source.count -= quantity; + target.count += quantity; + if (source.count == 0) Items(s.run, from).Remove(source); + return true; + }); + } + + public bool Uninstall(int slot, Hold to, int x, int y, bool rotated) + { + if (!ValidHold(to)) return false; + return Commit(s => + { + var r = s.run; + if (!Live(r) || slot < 0 || slot > 3) return false; + var item = r.installed.Find(i => InventoryGrid.Slot(i.itemId) == slot); + if (item == null || !InventoryGrid.Place(Items(r, to), Capacity(to), item, x, y, rotated)) return false; + r.installed.Remove(item); + r.equipment[slot] = -1; + return true; + }); + } + + public bool InstallItem(string uid, Hold from, Func fitsBudget) + { + if (!ValidHold(from) || fitsBudget == null) return false; + return Commit(s => + { + var r = s.run; + if (!Live(r)) return false; + var source = Items(r, from); + var item = source.Find(i => i.uid == uid); + if (item == null) return false; + int slot = InventoryGrid.Slot(item.itemId); + if (slot < 0) return false; + r.equipment[slot] = InventoryGrid.Index(slot, item.itemId); + if (!fitsBudget(r.equipment)) return false; + source.Remove(item); + var old = r.installed.Find(i => InventoryGrid.Slot(i.itemId) == slot); + if (old != null) + { + if (!InventoryGrid.AutoPlace(source, Capacity(from), old)) return false; + r.installed.Remove(old); + } + r.installed.Add(item); + return true; + }); + } + + static void Bank(GameSave s, CargoStack item) + { + if (item.borrowed) return; + if (InventoryGrid.Slot(item.itemId) >= 0) s.warehouse.Add(item); + else if (item.itemId == "alloy") s.alloy += item.count; + else if (item.itemId == "cells") s.cells += item.count; + else if (!s.blueprints.Contains(item.itemId)) s.blueprints.Add(item.itemId); + else s.alloy += 2; + } + + static List Manifest(RunRecord r, IReadOnlyCollection selection) + { + if (!Live(r) || r.podUsed || selection == null || selection.Count == 0 || selection.Count > 4) return null; + var ids = new HashSet(selection); + var picked = r.cargo.FindAll(i => ids.Contains(i.uid)); + return picked.Count == selection.Count && InventoryGrid.PodFits(picked) ? picked : null; + } + + public bool CanSendCargo(IReadOnlyCollection selection) => Manifest(Data.run, selection) != null; + + public bool SendCargo(IReadOnlyCollection selection) => Commit(s => + { + string receipt = s.run.id + ":cargo"; + var picked = Manifest(s.run, selection); + if (picked == null || s.receipts.Contains(receipt)) return false; + foreach (var item in picked) + { + Bank(s, item); + s.run.cargo.Remove(item); + } + s.run.podUsed = true; + s.receipts.Add(receipt); + return true; + }); + + public bool MarkDestroyed() => Commit(s => + { + if (!s.run.active) return false; + s.run.armor = 0; + s.run.destructionPending = true; + return true; + }); + + public bool Finish(bool destroyed) + { + if (destroyed && !Data.run.destructionPending && !MarkDestroyed()) return false; + return Write(s => + { + var r = s.run; + string receipt = r.id + ":return"; + if (!r.active || r.destructionPending != destroyed || s.receipts.Contains(receipt)) return false; + if (destroyed) s.receipts.Add(r.id + ":secure_eject"); + else + { + if (r.armor <= 0) return false; + foreach (var item in r.cargo) Bank(s, item); + foreach (var item in r.installed) Bank(s, item); + s.extractions++; + } + foreach (var item in r.safeCase) Bank(s, item); + r.safeCase.Clear(); + r.cargo.Clear(); + r.installed.Clear(); + r.active = false; + r.destructionPending = false; + s.receipts.Add(receipt); + return true; + }); + } + } +} diff --git a/Assets/Game/Scripts/Save/ProfileInventory.cs.meta b/Assets/Game/Scripts/Save/ProfileInventory.cs.meta new file mode 100644 index 0000000..5a31e51 --- /dev/null +++ b/Assets/Game/Scripts/Save/ProfileInventory.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 988a620c32c371a4da682504221e3651 \ No newline at end of file diff --git a/Assets/Game/Scripts/Save/ProfileStore.cs b/Assets/Game/Scripts/Save/ProfileStore.cs index 9ec2cd8..2d790ba 100644 --- a/Assets/Game/Scripts/Save/ProfileStore.cs +++ b/Assets/Game/Scripts/Save/ProfileStore.cs @@ -11,12 +11,14 @@ namespace Ironspore public string uid = Guid.NewGuid().ToString("N"); public string itemId; public int count; + public int x, y; + public bool rotated, borrowed; public CargoStack(string id, int amount) { itemId = id; count = amount; } - public string Name => GameLocalization.ItemName(itemId); + public string Name => InventoryGrid.Slot(itemId) >= 0 ? GameLocalization.Text("equipment." + itemId + ".name") : GameLocalization.ItemName(itemId); } [Serializable] @@ -39,13 +41,18 @@ namespace Ironspore public float elapsed; public float escortArmor = 250; public List cargo = new List(); + public List safeCase = new List(); + public List installed = new List(); + public bool destructionPending; public List searched = new List(); } [Serializable] public sealed class GameSave { - public int version = 1; + public const int CurrentVersion = 2; + public int version = CurrentVersion; + public List warehouse = new List(); public int alloy; public int cells; public int sorties; @@ -57,7 +64,7 @@ namespace Ironspore public RunRecord run = new RunRecord(); } - public sealed class ProfileStore + public sealed partial class ProfileStore { readonly string path; public GameSave Data @@ -69,10 +76,28 @@ namespace Ironspore get; private set; } + public static string SavePath(bool trial) + { +#if UNITY_EDITOR + string root = Path.Combine(Path.GetDirectoryName(Application.dataPath), "UserSettings"); +#else + string root = Application.persistentDataPath; +#endif + return Path.Combine(root, "Ironspore", trial ? "range-v2.json" : "profile-v2.json"); + } + public ProfileStore(string savePath) { path = savePath; - Data = Load(path) ?? Load(path + ".bak") ?? new GameSave(); + Data = Load(path) ?? Load(path + ".bak"); + if (Data == null) + { + if (File.Exists(path) || File.Exists(path + ".bak")) + throw new InvalidDataException("No valid equipment snapshot: " + path); + Data = new GameSave(); + for (int slot = 0; slot < 4; slot++) + Data.warehouse.Add(new CargoStack(InventoryGrid.EquipmentId(slot, 0), 1)); + } } static GameSave Load(string file) @@ -82,19 +107,25 @@ namespace Ironspore if (!File.Exists(file)) return null; var save = JsonUtility.FromJson(File.ReadAllText(file)); - if (save == null || save.version != 1 || save.run == null || save.receipts == null || save.blueprints == null || save.unlocked == null) - return null; - if (save.run.cargo == null || save.run.equipment == null || save.run.equipment.Length != 4) - return null; + if (save != null && save.version != GameSave.CurrentVersion) + throw new NotSupportedException("Unsupported equipment save version: " + save.version); + if (!Valid(save)) return null; return save; } - catch (Exception) { return null; } + catch (ArgumentException) { return null; } } public bool Commit(Func change) { + if (Data.run.destructionPending) return false; + return Write(change); + } + + bool Write(Func change) + { + Error = null; var candidate = JsonUtility.FromJson(JsonUtility.ToJson(Data)); - if (!change(candidate)) + if (!change(candidate) || !Valid(candidate)) return false; try { @@ -107,7 +138,7 @@ namespace Ironspore stream.Flush(true); } if (File.Exists(path)) - File.Replace(temp, path, path + ".bak"); + File.Replace(temp, path, Load(path) == null ? null : path + ".bak"); else File.Move(temp, path); Data = candidate; @@ -117,102 +148,31 @@ namespace Ironspore catch (Exception ex) { Error = ex.Message; return false; } } - public bool Begin(int chassis, int[] equipment, float armor, int seed) + public bool Begin(int chassis, int[] equipment, float armor, int seed, bool trial = false, string[] selected = null) { return Commit(s => { + if ((selected != null && selected.Length != 4) || s.run.active || equipment == null || equipment.Length != 4 || armor <= 0) return false; s.run = new RunRecord { id = Guid.NewGuid().ToString("N"), active = true, seed = seed, chassis = chassis, equipment = (int[])equipment.Clone(), armor = armor }; + for (int slot = 0; slot < 4; slot++) + { + string id = InventoryGrid.EquipmentId(slot, equipment[slot]); + if (id == null) return false; + var requested = selected?[slot]; + var item = s.warehouse.Find(part => part.itemId == id && (requested == null || requested == part.uid)); + if (item == null) + { + if (requested != null || (!trial && equipment[slot] != 0)) return false; + item = new CargoStack(id, 1) { borrowed = true }; + } + else s.warehouse.Remove(item); + s.run.installed.Add(item); + } s.sorties++; return true; }); } - public bool AddCargo(string id, int count, string sourceKey = "") - { - if (count <= 0) - return false; - return Commit(s => - { - if (!s.run.active) - return false; - if (sourceKey.Length > 0 && s.run.searched.Contains(sourceKey)) - return false; - foreach (var stack in s.run.cargo) - { - if (id.StartsWith("blueprint_") || stack.itemId != id || stack.count + count > 10) - continue; - stack.count += count; - if (sourceKey.Length > 0) - s.run.searched.Add(sourceKey); - return true; - } - if (s.run.cargo.Count >= 4 || (id.StartsWith("blueprint_") ? count != 1 : count > 10)) - return false; - s.run.cargo.Add(new CargoStack(id, count)); - if (sourceKey.Length > 0) - s.run.searched.Add(sourceKey); - return true; - }); - } - - static void Bank(GameSave s, CargoStack stack) - { - if (stack.itemId == "alloy") - s.alloy += stack.count; - else if (stack.itemId == "cells") - s.cells += stack.count; - else if (!s.blueprints.Contains(stack.itemId)) - s.blueprints.Add(stack.itemId); - else - s.alloy += 2; - } - - public bool SendCargo(IReadOnlyCollection selection) - { - if (selection == null || selection.Count == 0 || selection.Count > 2) - return false; - var ids = new HashSet(selection); - if (ids.Count != selection.Count) - return false; - return Commit(s => - { - string receipt = s.run.id + ":cargo"; - if (!s.run.active || s.run.armor <= 0 || s.run.podUsed || s.receipts.Contains(receipt)) - return false; - var picked = s.run.cargo.FindAll(x => ids.Contains(x.uid)); - if (picked.Count != ids.Count) - return false; - foreach (var stack in picked) - { - Bank(s, stack); - s.run.cargo.Remove(stack); - } - s.run.podUsed = true; - s.receipts.Add(receipt); - return true; - }); - } - - public bool Finish(bool destroyed) - { - return Commit(s => - { - string receipt = s.run.id + ":return"; - if (!s.run.active || s.receipts.Contains(receipt)) - return false; - if (!destroyed) - { - foreach (var stack in s.run.cargo) - Bank(s, stack); - s.extractions++; - } - s.run.cargo.Clear(); - s.run.active = false; - s.receipts.Add(receipt); - return true; - }); - } - public bool CompleteRoute() { return Commit(s => diff --git a/Assets/Game/Scripts/UI/GameHud.cs b/Assets/Game/Scripts/UI/GameHud.cs index 7bfe1ad..3841f62 100644 --- a/Assets/Game/Scripts/UI/GameHud.cs +++ b/Assets/Game/Scripts/UI/GameHud.cs @@ -1,11 +1,12 @@ using System; +using System.Linq; using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.UIElements; namespace Ironspore { - public sealed class GameHud : MonoBehaviour + public sealed partial class GameHud : MonoBehaviour { GameSession game; VisualElement root; @@ -37,6 +38,9 @@ namespace Ironspore root.styleSheets.Add(game.uiStyle); root.pickingMode = PickingMode.Ignore; root.style.unityFontDefinition = FontDefinition.FromSDFFont(game.localization.uiFont); + root.RegisterCallback(DragMove, TrickleDown.TrickleDown); + root.RegisterCallback(DragEnd, TrickleDown.TrickleDown); + root.RegisterCallback(_ => ResetDrag(), TrickleDown.TrickleDown); root.RegisterCallback(HandleLanguageKey, TrickleDown.TrickleDown); GameLocalization.Changed += RefreshLocale; } @@ -106,6 +110,7 @@ namespace Ironspore } void Clear() { + ResetDrag(); root.Clear(); mission = prompt = status = notice = telemetry = null; armor = energy = cargo = null; @@ -225,7 +230,7 @@ namespace Ironspore { int s = slot; var p = game.Part(s, game.InitialEquipment[s]); - Button(content, L("ui.home.part", L("ui.slot." + s), p.LocalizedName, p.mass, p.power), () => { game.InitialEquipment[s] = (game.InitialEquipment[s] + 1) % game.PartCount(s); ShowHangar(); }, "loadout"); + Button(content, L("ui.home.part", L("ui.slot." + s), p.LocalizedName, p.mass, p.power), () => { game.SelectedInstances[s] = null; game.InitialEquipment[s] = (game.InitialEquipment[s] + 1) % game.PartCount(s); ShowHangar(); }, "loadout"); } float mass = 0, power = 0; for (int i = 0; i < 4; i++) @@ -249,12 +254,27 @@ namespace Ironspore var save = game.Store.Data; Text(content, L("ui.home.inventory"), "section"); Text(content, L("ui.home.stock", save.alloy, save.cells, save.blueprints.Count, save.extractions, save.completed), "budget"); + foreach (var item in save.warehouse) + { + int slot = InventoryGrid.Slot(item.itemId); + Button(content, L("ui.home.chooseItem", item.Name, item.uid.Substring(0, Math.Min(6, item.uid.Length))), () => + { + game.SelectedInstances[slot] = item.uid; + game.InitialEquipment[slot] = InventoryGrid.Index(slot, item.itemId); + ShowHangar(); + }, game.SelectedInstances[slot] == item.uid ? "loadout selected" : "loadout"); + } foreach (string blueprint in save.blueprints) { string id = blueprint; bool unlocked = save.unlocked.Contains(id); var b = Button(content, L(unlocked ? "ui.home.researched" : "ui.home.research", GameLocalization.ItemName(id)), () => game.Unlock(id), "loadout"); b.SetEnabled(!unlocked); + var recipe = InventoryGrid.Recipe(id); + if (unlocked && recipe.item != null) + Button(content, L("ui.home.manufacture", L("equipment." + recipe.item + ".name"), recipe.alloy, recipe.cells), + () => { if (!game.Store.Manufacture(id)) game.Notify("notice.saveFailed"); ShowHangar(); }, "loadout") + .SetEnabled(!save.run.active && save.alloy >= recipe.alloy && save.cells >= recipe.cells); } hangarNotice = Text(content, game.Notice, "warning"); RefreshHangarNotice(); @@ -314,19 +334,6 @@ namespace Ironspore Text(panel, body, "body"); return panel; } - public void ShowCargo() - { - var panel = Overlay(L("ui.cargo.title"), L(game.Run.podUsed ? "ui.cargo.used" : "ui.cargo.help")); - view = View.Cargo; - foreach (var stack in game.Run.cargo) - { - string uid = stack.uid; - var b = Button(panel, L("ui.cargo.stack", game.SelectedCargo.Contains(uid) ? L("ui.cargo.selected") : "[ ]", stack.Name, stack.count), () => game.ToggleCargo(uid), "loadout"); - b.SetEnabled(!game.Run.podUsed); - } - Text(panel, L("ui.cargo.capacity", game.Run.cargo.Count, game.SelectedCargo.Count), "budget"); - Button(panel, L("ui.return"), game.CloseMenu, "primary"); - } public void ShowPart(int slot, int index) { index = Mathf.Clamp(index, 0, game.PartCount(slot) - 1); @@ -337,7 +344,7 @@ namespace Ironspore var next = game.Part(slot, index); var panel = Overlay(L("ui.refit.title"), L("ui.refit.help")); view = View.Part; - Text(panel, L("ui.refit.equipped", old.LocalizedName, old.mass, old.power), "body"); + Text(panel, L("ui.refit.equipped", old?.LocalizedName ?? "—", old?.mass ?? 0, old?.power ?? 0), "body"); Text(panel, L("ui.refit.recovered", next.LocalizedName, next.mass, next.power), "budget"); Button(panel, L("ui.refit.install"), () => game.Install(slot, partIndex), "primary"); Button(panel, L("ui.refit.keep"), game.CloseMenu); @@ -378,6 +385,7 @@ namespace Ironspore ActivateFocusedLanguage(); } } + if (view == View.Cargo && selectedUid != null && Keyboard.current?.rKey.wasPressedThisFrame == true) RotateSelection(); if (!game.InField || !game.Pilot) return; var run = game.Run; @@ -409,7 +417,7 @@ namespace Ironspore if (notice != null) notice.text = game.Notice; if (telemetry != null) - telemetry.text = L("ui.hud.telemetry", game.chassis[run.chassis].LocalizedName, L(p.FirstPerson ? "ui.hud.cockpit" : "ui.hud.shoulder"), game.Part(0, run.equipment[0]).LocalizedName, run.cargo.Count, game.World.ExtractionRemaining >= 0 ? game.World.TransportReady ? L("ui.hud.transportReady") : L("ui.hud.transportEta", Mathf.CeilToInt(game.World.ExtractionRemaining)) : L("ui.hud.extractionAvailable")); + telemetry.text = L("ui.hud.telemetry", game.chassis[run.chassis].LocalizedName, L(p.FirstPerson ? "ui.hud.cockpit" : "ui.hud.shoulder"), game.Part(0, run.equipment[0])?.LocalizedName ?? "—", run.cargo.Sum(InventoryGrid.Area), game.World.ExtractionRemaining >= 0 ? game.World.TransportReady ? L("ui.hud.transportReady") : L("ui.hud.transportEta", Mathf.CeilToInt(game.World.ExtractionRemaining)) : L("ui.hud.extractionAvailable")); if (cross != null) cross.style.opacity = p.RecentHit ? 1 : .65f; } diff --git a/Assets/Game/Scripts/UI/GameHudInventory.cs b/Assets/Game/Scripts/UI/GameHudInventory.cs new file mode 100644 index 0000000..d128a5d --- /dev/null +++ b/Assets/Game/Scripts/UI/GameHudInventory.cs @@ -0,0 +1,241 @@ +using System.Collections.Generic; +using System.Linq; +using UnityEngine; +using UnityEngine.UIElements; + +namespace Ironspore +{ + public sealed partial class GameHud + { + string selectedUid; + Hold selectedHold; + int selectedSlot = -1; + bool rotated; + int quantity; + const int CellWidth = 103, CellHeight = 55; + + sealed class GridCell + { + public Hold hold; + public int x, y; + public CargoStack occupant; + } + + CargoStack dragItem; + Hold dragHold; + int dragSlot, dragPointer; + Vector2 dragStart; + VisualElement dragPreview; + + CargoStack Selection => selectedSlot >= 0 ? game.Run.installed.Find(i => i.uid == selectedUid) : + Contents(selectedHold).Find(i => i.uid == selectedUid); + + List Contents(Hold hold) => hold == Hold.Cargo ? game.Run.cargo : game.Run.safeCase; + + void SelectInventoryItem(CargoStack item, Hold hold, int slot = -1) + { + selectedUid = item?.uid; + selectedSlot = slot; + selectedHold = hold; + rotated = item?.rotated ?? false; + quantity = item?.count ?? 0; + ShowCargo(); + } + + void RotateSelection() + { + rotated = !rotated; + if (dragPreview == null) ShowCargo(); + else SizePreview(); + } + + public void ShowCargo() + { + var panel = Overlay(L("ui.cargo.title"), L(game.Run.podUsed ? "ui.cargo.used" : "ui.cargo.help")); + view = View.Cargo; + var installed = Element(panel, "row"); + foreach (var part in game.Run.installed) + { + int slot = InventoryGrid.Slot(part.itemId); + var button = Button(installed, part.Name, () => SelectInventoryItem(part, Hold.Cargo, slot), + part.uid == selectedUid ? "loadout selected" : "loadout"); + EnableDrag(button, part, Hold.Cargo, slot); + } + var grids = Element(panel, "row"); + DrawHold(grids, Hold.Cargo, InventoryGrid.CargoSize); + DrawHold(grids, Hold.SafeCase, InventoryGrid.CaseSize); + Text(panel, L("ui.cargo.capacity", game.Run.cargo.Sum(InventoryGrid.Area), + game.Run.cargo.Where(i => game.SelectedCargo.Contains(i.uid)).Sum(InventoryGrid.Area)), "budget"); + var item = Selection; + if (item != null) + { + InventoryGrid.Size(item.itemId, rotated, out int w, out int h); + Text(panel, L("ui.cargo.selection", item.Name, w, h, quantity), "body"); + var actions = Element(panel, "row"); + Button(actions, L("ui.cargo.rotate"), RotateSelection); + if (item.count > 1) + { + Button(actions, "−", () => { quantity--; ShowCargo(); }).SetEnabled(quantity > 1); + Button(actions, "+", () => { quantity++; ShowCargo(); }).SetEnabled(quantity < item.count); + } + if (selectedSlot < 0) + { + if (selectedHold == Hold.Cargo && !game.Run.podUsed) + Button(actions, L(game.SelectedCargo.Contains(item.uid) ? "ui.cargo.unmark" : "ui.cargo.mark"), () => game.ToggleCargo(item.uid)); + if (InventoryGrid.Slot(item.itemId) >= 0) + Button(actions, L("ui.cargo.install"), () => + { + if (game.InstallItem(item, selectedHold)) selectedUid = null; + ShowCargo(); + }); + } + Button(actions, L("ui.cargo.cancel"), () => SelectInventoryItem(null, Hold.Cargo)); + } + Text(panel, game.Notice, "warning"); + Button(panel, L("ui.return"), game.CloseMenu, "primary"); + } + + void DrawHold(VisualElement parent, Hold hold, int size) + { + var column = Element(parent, "inventory-column"); + Text(column, L(hold == Hold.Cargo ? "ui.cargo.hold" : "ui.cargo.case"), "section"); + var grid = new VisualElement(); + column.Add(grid); + grid.style.width = size * CellWidth; + grid.style.height = size * CellHeight; + var items = Contents(hold); + for (int y = 0; y < size; y++) + for (int x = 0; x < size; x++) + { + int cx = x, cy = y; + var occupant = items.Find(i => + { + InventoryGrid.Size(i.itemId, i.rotated, out int w, out int h); + return cx >= i.x && cy >= i.y && cx < i.x + w && cy < i.y + h; + }); + var cell = Button(grid, "", () => InventoryClick(hold, cx, cy, occupant), "inventory-cell"); + cell.name = hold + "-" + x + "-" + y; + cell.tooltip = occupant?.Name ?? ""; + cell.userData = new GridCell { hold = hold, x = x, y = y, occupant = occupant }; + if (occupant != null) EnableDrag(cell, occupant, hold, -1); + PositionInGrid(cell, x, y, 1, 1); + } + foreach (var item in items) + { + InventoryGrid.Size(item.itemId, item.rotated, out int w, out int h); + string prefix = game.SelectedCargo.Contains(item.uid) ? "✓ " : ""; + var label = Text(grid, prefix + item.Name + (item.count > 1 ? " ×" + item.count : ""), "inventory-item"); + label.pickingMode = PickingMode.Ignore; + if (item.uid == selectedUid) label.AddToClassList("selected"); + PositionInGrid(label, item.x, item.y, w, h); + } + } + + void EnableDrag(Button button, CargoStack item, Hold hold, int slot) + { + // Captured pointer events can be dispatched directly to the pressed button. + button.RegisterCallback(DragMove, TrickleDown.TrickleDown); + button.RegisterCallback(DragEnd, TrickleDown.TrickleDown); + button.RegisterCallback(evt => + { + if (evt.button != 0) return; + dragItem = item; + dragHold = hold; + dragSlot = slot; + dragStart = evt.position; + dragPointer = evt.pointerId; + }, TrickleDown.TrickleDown); + } + + void DragMove(PointerMoveEvent evt) + { + if (dragItem == null || evt.pointerId != dragPointer) return; + Vector2 pos = evt.position; + if (dragPreview == null) + { + if ((pos - dragStart).sqrMagnitude < 36) return; + if (selectedUid != dragItem.uid) + { + quantity = dragItem.count; + rotated = dragItem.rotated; + } + selectedUid = dragItem.uid; + selectedSlot = dragSlot; + selectedHold = dragHold; + dragPreview = Text(root, dragItem.Name, "inventory-item selected"); + dragPreview.pickingMode = PickingMode.Ignore; + dragPreview.style.position = UnityEngine.UIElements.Position.Absolute; + SizePreview(); + } + var target = root.panel.Pick(pos); + Vector2 anchor = target?.userData is GridCell ? target.worldBound.min : pos; + Vector2 local = root.WorldToLocal(anchor); + dragPreview.style.left = local.x; + dragPreview.style.top = local.y; + } + + void SizePreview() + { + InventoryGrid.Size(dragItem.itemId, rotated, out int w, out int h); + dragPreview.style.width = CellWidth * w - 2; + dragPreview.style.height = CellHeight * h - 2; + } + + void DragEnd(PointerUpEvent evt) + { + if (dragItem == null || evt.pointerId != dragPointer) return; + bool dragged = dragPreview != null; + ResetDrag(); + if (!dragged) return; + evt.StopImmediatePropagation(); + var owner = root.panel.GetCapturingElement(evt.pointerId) as VisualElement; + owner?.ReleasePointer(evt.pointerId); + var target = root.panel.Pick(evt.position)?.userData as GridCell; + if (target != null) InventoryClick(target.hold, target.x, target.y, target.occupant); + else ShowCargo(); + } + + void ResetDrag() + { + dragPreview?.RemoveFromHierarchy(); + dragPreview = null; + dragItem = null; + } + + static void PositionInGrid(VisualElement element, int x, int y, int w, int h) + { + element.style.position = UnityEngine.UIElements.Position.Absolute; + element.style.left = x * CellWidth; + element.style.top = y * CellHeight; + element.style.width = w * CellWidth - 2; + element.style.height = h * CellHeight - 2; + } + + void InventoryClick(Hold hold, int x, int y, CargoStack occupant) + { + var item = Selection; + if (item == null) + { + SelectInventoryItem(occupant, hold); + return; + } + bool ok; + if (selectedSlot >= 0) + ok = game.Store.Uninstall(selectedSlot, hold, x, y, rotated); + else if (occupant == null || item.uid == occupant.uid) + ok = game.Store.MoveItem(item.uid, selectedHold, hold, x, y, rotated, quantity); + else if (item.itemId == occupant.itemId && InventoryGrid.IsMaterial(item.itemId)) + ok = game.Store.MergeMaterial(item.uid, selectedHold, occupant.uid, hold, quantity); + else + ok = quantity == item.count && game.Store.SwapItems(item.uid, selectedHold, occupant.uid, hold, rotated); + if (ok) + { + selectedUid = null; + game.SelectedCargo.RemoveAll(uid => !game.Run.cargo.Exists(i => i.uid == uid)); + game.Pilot.ApplyLoadout(); + } + else game.Notify(string.IsNullOrEmpty(game.Store.Error) ? "notice.placement" : "notice.saveFailed"); + ShowCargo(); + } + } +} diff --git a/Assets/Game/Scripts/UI/GameHudInventory.cs.meta b/Assets/Game/Scripts/UI/GameHudInventory.cs.meta new file mode 100644 index 0000000..5823caa --- /dev/null +++ b/Assets/Game/Scripts/UI/GameHudInventory.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6faff651d0fe95448b2711a92bce81d3 \ No newline at end of file diff --git a/Assets/Game/UI/GameHud.uss b/Assets/Game/UI/GameHud.uss index b66329f..26629d0 100644 --- a/Assets/Game/UI/GameHud.uss +++ b/Assets/Game/UI/GameHud.uss @@ -53,3 +53,9 @@ .language-option:hover,.language-option:focus { background-color:rgb(43,79,78); border-color:rgb(127,240,204); } .language-option.selected { background-color:rgb(38,77,72); border-color:rgb(127,240,204); } .language-dismiss { position:absolute; left:0; right:0; top:0; bottom:0; } + +.inventory-column { margin-right:16px; } +.inventory-cell { background-color:rgb(22,41,47); border-width:1px; border-color:rgb(58,91,91); margin:0; padding:0; } +.inventory-cell:hover,.inventory-cell:focus { border-color:rgb(135,235,200); background-color:rgb(38,77,72); } +.inventory-item { background-color:rgba(47,85,83,.8); color:rgb(225,239,233); border-width:1px; border-color:rgb(80,137,126); -unity-text-align:middle-center; white-space:normal; font-size:13px; } +.inventory-item.selected { border-color:rgb(155,247,207); background-color:rgba(61,115,105,.85); } diff --git a/Assets/Tests/EditMode/LocalizationTests.cs b/Assets/Tests/EditMode/LocalizationTests.cs index 3745fe3..6270344 100644 --- a/Assets/Tests/EditMode/LocalizationTests.cs +++ b/Assets/Tests/EditMode/LocalizationTests.cs @@ -97,7 +97,7 @@ namespace Ironspore.Tests StringAssert.Contains("×3", notice.ToString()); StringAssert.Contains("电磁电容", state.run.cargo[0].Name); Assert.AreEqual(before, JsonUtility.ToJson(state)); - Assert.AreEqual(1, state.version); + Assert.AreEqual(GameSave.CurrentVersion, state.version); } [Test] public void MissingTranslationUsesEnglishFallback() diff --git a/Assets/Tests/EditMode/ProfileStoreTests.cs b/Assets/Tests/EditMode/ProfileStoreTests.cs index 8e9fc58..492e53c 100644 --- a/Assets/Tests/EditMode/ProfileStoreTests.cs +++ b/Assets/Tests/EditMode/ProfileStoreTests.cs @@ -42,14 +42,15 @@ namespace Ironspore.Tests [Test] public void PodRejectsOvercapacityAndDuplicateSelectionsWithoutConsuming() { - for (int i = 0; i < 3; i++) + for (int i = 0; i < 5; i++) Assert.IsTrue(store.AddCargo("blueprint_" + i, 1, "crate" + i)); var bag = store.Data.run.cargo; - Assert.IsFalse(store.SendCargo(new[] { bag[0].uid, bag[1].uid, bag[2].uid })); + Assert.IsFalse(store.SendCargo(new[] { bag[0].uid, bag[1].uid, bag[2].uid, bag[3].uid, bag[4].uid })); Assert.IsFalse(store.SendCargo(new[] { bag[0].uid, bag[0].uid })); Assert.IsFalse(store.Data.run.podUsed); - Assert.AreEqual(3, store.Data.run.cargo.Count); + Assert.AreEqual(5, store.Data.run.cargo.Count); Assert.AreEqual(0, store.Data.blueprints.Count); + Assert.IsTrue(store.SendCargo(new[] { bag[0].uid, bag[1].uid, bag[2].uid, bag[3].uid })); } [Test] public void SearchedSourceCannotDuplicateLootAfterReload() @@ -77,6 +78,125 @@ namespace Ironspore.Tests Assert.AreEqual(1, store.Data.completed); Assert.AreEqual(1, store.Data.extractions); } + [Test] + public void SafeCaseChecksShapeAndPersistsRotatedWeapon() + { + Assert.IsFalse(store.Uninstall(0, Hold.SafeCase, 0, 0, false)); + Assert.IsFalse(store.Uninstall(0, Hold.SafeCase, 0, 0, true)); + Assert.IsTrue(store.AddCargo("cells", 1)); + Assert.IsTrue(store.MoveItem(store.Data.run.cargo[0].uid, Hold.Cargo, Hold.SafeCase, 0, 1)); + string weapon = store.Data.run.installed.Find(i => i.itemId == "guided").uid; + Assert.IsFalse(store.Uninstall(1, Hold.SafeCase, 0, 0, false)); + Assert.IsTrue(store.Uninstall(1, Hold.SafeCase, 0, 0, true)); + store = new ProfileStore(path); + var saved = store.Data.run.safeCase.Find(i => i.uid == weapon); + Assert.IsTrue(saved.rotated); + Assert.AreEqual(0, saved.x); + Assert.AreEqual(0, saved.y); + Assert.AreEqual(-1, store.Data.run.equipment[1]); + Assert.IsTrue(store.Finish(false)); + Assert.IsTrue(store.Begin(1, new[] { 0, 0, 0, 0 }, 180, 8)); + Assert.IsTrue(store.Data.run.installed.Exists(i => i.uid == weapon)); + Assert.IsFalse(store.Data.warehouse.Exists(i => i.uid == weapon)); + } + + [Test] + public void DeathLocksInventoryAndRetriesOnlyTheProtectedReturn() + { + string run = store.Data.run.id; + Assert.IsTrue(store.AddCargo("alloy", 4, "a", "pulse")); + string pulse = store.Data.run.cargo.Find(i => i.itemId == "pulse").uid; + Assert.IsTrue(store.MoveItem(pulse, Hold.Cargo, Hold.SafeCase)); + Assert.IsTrue(store.SendCargo(new[] { store.Data.run.cargo[0].uid })); + Assert.IsTrue(store.AddCargo("coil", 1, "b")); + Assert.IsTrue(store.MarkDestroyed()); + store = new ProfileStore(path); + Assert.IsFalse(store.MoveItem(pulse, Hold.SafeCase, Hold.Cargo)); + Assert.IsFalse(store.Finish(false)); + Directory.CreateDirectory(path + ".tmp"); + Assert.IsFalse(store.Finish(true)); + Assert.IsTrue(store.Data.run.destructionPending); + Assert.AreEqual(0, store.Data.warehouse.Count); + Directory.Delete(path + ".tmp"); + store = new ProfileStore(path); + Assert.IsTrue(store.Finish(true)); + Assert.IsFalse(store.Finish(true)); + Assert.AreEqual(4, store.Data.alloy); + Assert.AreEqual(1, store.Data.warehouse.Count); + Assert.AreEqual(pulse, store.Data.warehouse[0].uid); + CollectionAssert.AreEquivalent(new[] { run + ":secure_eject", run + ":cargo", run + ":return" }, store.Data.receipts); + } + + [Test] + public void SplitMergeSwapAndAtomicPlacementPreserveOwnership() + { + Assert.IsTrue(store.AddCargo("cells", 10)); + string cells = store.Data.run.cargo[0].uid; + Assert.IsTrue(store.MoveItem(cells, Hold.Cargo, Hold.SafeCase, 1, 1, false, 4)); + string split = store.Data.run.safeCase[0].uid; + Assert.AreNotEqual(cells, split); + Assert.IsFalse(store.MergeMaterial(cells, Hold.Cargo, split, Hold.SafeCase, 7)); + Assert.IsTrue(store.MergeMaterial(cells, Hold.Cargo, split, Hold.SafeCase, 2)); + Assert.IsTrue(store.AddCargo("rapid", 1)); + string rapid = store.Data.run.cargo.Find(i => i.itemId == "rapid").uid; + Assert.IsFalse(store.SwapItems(rapid, Hold.Cargo, split, Hold.SafeCase, true)); + Assert.IsTrue(store.SwapItems(cells, Hold.Cargo, split, Hold.SafeCase, false)); + Assert.IsFalse(store.MoveItem(rapid, Hold.Cargo, Hold.SafeCase)); + Assert.IsTrue(store.Finish(true)); + Assert.AreEqual(4, store.Data.cells); + } + + [Test] + public void FailedRefitOrWriteKeepsOldSlotAndLootSource() + { + Assert.IsTrue(store.AddCargo("alloy", 1, "crate", "coil")); + string uid = store.Data.run.cargo.Find(i => i.itemId == "coil").uid; + Assert.IsFalse(store.InstallItem(uid, Hold.Cargo, parts => false)); + Assert.AreEqual(0, store.Data.run.equipment[0]); + Directory.CreateDirectory(path + ".tmp"); + Assert.IsFalse(store.Uninstall(1, Hold.SafeCase, 0, 0, false)); + Assert.IsFalse(store.AddCargo("alloy", 1, "retry")); + Assert.IsFalse(store.Data.run.searched.Contains("retry")); + Directory.Delete(path + ".tmp"); + Assert.IsTrue(store.InstallItem(uid, Hold.Cargo, parts => true)); + Assert.AreEqual(1, store.Data.run.equipment[0]); + Assert.IsTrue(store.Data.run.cargo.Exists(i => i.itemId == "rapid")); + Assert.IsTrue(store.Data.run.installed.Exists(i => i.uid == uid)); + } + + [Test] + public void BorrowedStarterDoesNotReplaceLostStock() + { + Assert.IsTrue(store.Finish(true)); + Assert.IsFalse(store.Begin(1, new[] { 1, 0, 0, 0 }, 180, 8)); + Assert.IsTrue(store.Begin(1, new[] { 0, 0, 0, 0 }, 180, 8)); + Assert.IsTrue(store.Data.run.installed.TrueForAll(i => i.borrowed)); + Assert.IsTrue(store.Uninstall(1, Hold.SafeCase, 0, 0, false)); + Assert.IsTrue(store.Finish(false)); + Assert.IsEmpty(store.Data.warehouse); + } + + [Test] + public void ManufacturingRequiresResearchAndMaterialsAndCreatesDistinctInstances() + { + Assert.IsTrue(store.Finish(false)); + const string blueprint = "blueprint_coil_capacitor"; + Assert.IsTrue(store.Commit(s => { s.alloy = 8; s.cells = 4; return true; })); + Assert.IsFalse(store.Manufacture(blueprint)); + Assert.IsTrue(store.Commit(s => { s.unlocked.Add(blueprint); return true; })); + Assert.IsTrue(store.Manufacture(blueprint)); + Assert.IsTrue(store.Manufacture(blueprint)); + Assert.IsFalse(store.Manufacture(blueprint)); + var copies = store.Data.warehouse.FindAll(i => i.itemId == "coil"); + Assert.AreNotEqual(copies[0].uid, copies[1].uid); + Assert.IsTrue(store.Begin(1, new[] { 1, 0, 0, 0 }, 180, 8, selected: new[] { copies[1].uid, null, null, null })); + Assert.IsTrue(store.Data.run.installed.Exists(i => i.uid == copies[1].uid)); + Assert.IsTrue(store.Finish(true)); + Assert.IsTrue(store.Data.warehouse.Exists(i => i.uid == copies[0].uid)); + Assert.AreEqual(0, store.Data.alloy); + Assert.AreEqual(0, store.Data.cells); + } + [Test] public void WriteFailureLeavesOriginalMemoryState() { diff --git a/Docs/Design/GameDesign.md b/Docs/Design/GameDesign.md index 3a0be7a..e70588b 100644 --- a/Docs/Design/GameDesign.md +++ b/Docs/Design/GameDesign.md @@ -12,9 +12,12 @@ | 机体与改装 | 底盘给出基础机动、承重与能源,装备及改装决定实际打法。出击前选初始配装,局内可以更换装备并追加改装。 | | 单局结构 | 完整通关目标为 20–30 分钟,包含选路、搜索和改装。沿分支路线进入独立战区,中段选择首领战或护送任务,两条路线均可通关。提前整机撤离可缩短本局。 | | 战区与情报 | 选路时显示任务目标和物资类别。例如「重启升降机 · 推进器零件较多」。具体型号、数量和品质进场搜索后揭晓。普通战区完成目标即开放下一站,玩家也可留场继续搜。 | -| 运货 | 每次出击配发 1 个货物回收舱,共 2 格。每格装 1 张蓝图或 1 组有堆叠上限的同类材料。目标完成前即可使用。机甲停下操作几秒,舱升空才消耗并入库。中断时舱和货物都留在机甲上,可重试。 | +| 运货 | 每次出击配发 1 个主动货物回收舱,按物品长宽在舱内实际摆放,可旋转;不是按条目数计容量。目标完成前即可使用。机甲停下操作几秒,舱升空才消耗并入库。中断时舱和货物都留在机甲上,可重试。 | +| 弹射式安全箱 | 独立于主动货舱的保全空间,机体被摧毁时自动弹射回基地。任何放得下的未安装物品均可放入,包括完整武器和机体模块;放入成功的物品在机毁时保全。 | | 整机撤离 | **每个战区**都有撤离点。抵达后可呼叫接应,等待期间能移动、作战,运输机到达后主动登机。登机结算随身战利品并结束出击。 | -| 跨局成长 | 已解锁的底盘、装备蓝图和配装方案永久保留。成功回收的材料与蓝图进入机库。局内实际捡到的武器、替换件和临时强化在出击结束时重置。 | +| 跨局成长 | 已解锁的底盘、装备蓝图和配装方案永久保留。装备实物可带出入库、下局带入;机毁时安全箱内容弹射保全,箱外携带装备与未回收物资损失,机库库存与已回收物资保留。局内临时强化在出击结束时重置。 | + +2026-11-12,用户已确认上述实物跨局规则、弹射式安全箱及可旋转的二维格子库存。具体工作按[装备系统扩展计划](../Production/EquipmentSystemPlan.md)与[安全箱和多格库存计划](../Production/EjectionSafeCaseAndGridPlan.md)推进;当前原型已接入实物库存、安全箱与二维网格,使用独立 v2 新档。 首页机库的视口右下角提供语言切换,首批支持简体中文与 English,即时刷新并保存偏好。所有玩法界面、物品、通知和标牌共用语言配置,出击与结算数据保持稳定。实施与验收见 [多语言计划](../Production/LocalizationPlan.md)。 @@ -31,7 +34,7 @@ 推荐用手工制作的战区拼出路线,让同一个区域轮换任务、物资分布和敌人组合。每条路线都要有重装机体可走的地面通路,也给高速机体留出跨层移动的空间。普通推进目标可以是重启装卸平台或回收导航模块,守卫与额外物资给玩家留下作战的理由。 -**一个典型抉择。** 玩家在第二站找到推进器蓝图、一组合金和一组电芯。可以先把蓝图和合金装进两格货舱。成功发射后,电芯与此后新捡到的物资要由整机带回。此时玩家可以去本区撤离点呼叫运输机,也可以完成战区目标,再去中段选择终点。 +**一个典型抉择。** 按首测建议尺寸,玩家找到 2×3 的主武器、2×2 的完整推进器和 1×1 的蓝图。2×2 安全箱可以装下推进器,或选择蓝图并为其他小件留位;主武器需要更大的安全箱或整机撤离。主动货舱还可先送回一批装得下的货物。玩家据此决定保住哪件物品、是否冒险继续探索。 ## 驾驶、战斗与装备 @@ -58,27 +61,28 @@ | 左 Shift、空格 | Shift 沿移动方向推进。空格起跳,有升空能力的推进器支持按住爬升。 | | V | 在越肩与驾驶舱之间切换,继承当前瞄准方向。 | | E | 对准目标后按提示搜索、操作设施或呼叫接应。运输机抵达后须再次按 E 才会登机。 | -| Tab、G | Tab 打开货仓,用鼠标选择要送回的货物。回到驾驶状态后,在原地按住 G 准备发射,松开或受击按中断处理。 | +| Tab、R、G | Tab 打开普通货仓与安全箱,拖拽时 R 旋转物品;主动货舱先完成装舱预览。回到驾驶状态后原地按住 G 准备发射,松开或受击按中断处理。安全箱在机毁时自动弹射。 | | Esc | 关闭当前界面或打开暂停菜单。 | 驾驶时鼠标控制准星。打开货仓或路线图时显示指针,由界面接收点击。回到驾驶时,主武器等待下一次按下左键再开火。HUD 中央显示准星与当前交互提示,边缘显示能源、装甲、货舱格位和接应状态。驾驶舱边框为这些信息留出空间。 ## 搜索、回收与存档 -搜索点直接放在场景里。货架、补给箱和可拆取的机甲残骸提示物资大类,交互时显示实际内容。战利品界面区分「现在可装」的局内装备与「可送回机库」的材料、蓝图。第一张蓝图增加机库选项,重复蓝图推荐折成对应材料。材料用来解锁新的配装选择。 +搜索点直接放在场景里。货架、补给箱和可拆取的机甲残骸提示物资大类,交互时显示实际内容。战利品包括可立即安装或带出入库的装备实物,以及材料、蓝图。第一张蓝图增加研究选项,重复蓝图推荐折成对应材料;具体制造与材料消耗规则按装备系统扩展计划细化。 -例如,找到一件推进器实物时,可以在本局装上试用。找到同款蓝图并成功带回后,玩家在机库用材料解锁该推进器,下次出击就能把它编入初始配装。首轮经济测试先用合金、电芯两种材料,每种每 10 个成一组。机甲随身货仓先试 4 格,货物回收舱从中选至多 2 格送回。 +例如,找到一件推进器实物时,可以在本局装上试用;成功带出后,这件实物进入机库,下次出击可再次携入。如果提前卸下并成功放入安全箱,机毁也可保全。对应蓝图用于研究该型号的后续制作与配装选项,具体规则见装备系统扩展计划。材料每堆最多 10 个;首测建议普通货仓 4×4、主动货舱 2×2、安全箱 2×2,尺寸可调。物品可占多格并旋转,既要有足够空格,也要有符合形状的连续空位。 | 事件 | 结算结果 | | --- | --- | | 装舱或发射准备被打断 | 解除所选货物的占用,物品仍在随身清单,货舱还可使用。 | | 货舱成功升空 | 同一次存档写入货物入库、从随身清单扣除、货舱已用三项变化,然后显示成功反馈。机甲可继续本局。 | -| 已呼叫整机接应,运输机到达 | 提示「可登机」。玩家仍控制机甲,主动登机时才结算随身材料与蓝图。 | +| 安全箱物品放入或取出 | 按实际摆放保存唯一归属,放入成功后成为机毁可保全物品,取出后按其新位置处理。箱内物品不能仍在原容器或装备槽保留一份。 | +| 已呼叫整机接应,运输机到达 | 提示「可登机」。玩家仍控制机甲,主动登机时才回收入库本次携带的装备实物、材料与蓝图。 | | 未完成终局就登机 | 记录为成功提前撤离,保住已携带的可回收物资。 | | 击败首领或护送到达 | 记录本局路线目标达成。通关记录与随身货物的入库分别处理,带出随身货物需要完成登机。护送目标损毁时,存活的机甲仍可从本区撤离。 | -| 机甲被击毁 | 失去尚在机上的战利品,本局装备与强化一起重置。之前已成功送回的物资归机库所有。 | +| 机甲被击毁 | 锁定机毁现场,安全箱自动弹射,箱内物品返基地;箱外装备实物与未回收战利品损失,局内临时强化重置。机库库存及之前已成功送回的物资保留。 | -初测可用 **3 秒**货舱发射准备和 **15 秒**整机接应等待。地图和 HUD 显示撤离点、接应进度、是否可登机。存档按「出击编号+事件类型」分别登记货舱发射、终局目标达成和整机返航,每个事件只结算一次。重新加载时恢复已用的货舱、机库与剩余随身物资。试玩时比较提前撤离和深入终局的收益,再调整战利品价值和敌群压力。 +初测可用 **3 秒**主动货舱发射准备和 **15 秒**整机接应等待;安全箱机毁弹射不需要额外输入。地图和 HUD 显示撤离点、接应进度、是否可登机及安全箱保护状态。存档按「出击编号+事件类型」分别登记主动货舱发射、安全箱弹射、终局目标达成和整机返航,每个事件只结算一次。机毁保全、普通物资损失与结束出击共同提交,重新加载保持布局、归属和回收状态。试玩比较提前撤离、主动回收与机毁保全的收益,再调整容量、战利品价值和敌群压力。 ## 首轮内容预算 @@ -94,9 +98,9 @@ | 阶段 | 交付一个可以实际操作的结果 | 通过条件 | | --- | --- | --- | | 1 · 驾驶试验场 | 一个有地面和高差的灰盒场景、一种武器、一个敌人、三套底盘参数和双视角。 | 在 Windows 独立构建中用键鼠操作。三套底盘都能到达目标并作战,两种视角均可瞄准、推进和射击,连续切换时射击判定一致。 | -| 2 · 单战区搜打撤 | 一项推进目标、一个搜索点、一处撤离点,以及货舱与本地机库存档。 | 在目标完成前试一次货舱中断和一次成功发射。分别试玩带货登机与机毁,重开后核对机库物资、舱数与两格上限。 | +| 2 · 单战区搜打撤 | 一项推进目标、一个搜索点、一处撤离点,以及主动货舱、安全箱与本地机库存档。 | 在目标完成前试一次货舱中断和一次成功发射。分别试玩带货登机与机毁,重开后核对二维布局、货舱次数、安全箱保全和机库物资。 | | 3 · 构筑与分支 | 机库初装、局内换件、可复用的普通战区、带情报的路线图和两种终局。 | 完成目标开放下一站,任何战区均能整机撤离。中段可选择两条路线,并能用前半程获得的部件改变后半程打法。 | -| 4 · 完整出击 | 把两种终局、三种机体风格、永久解锁与局内重置串成可重复游玩的版本。 | 三类底盘各试首领与护送,两种视角均有完整战斗记录。记录完整通关用时,按 20–30 分钟目标调节。提前撤离、货舱发射后机毁、终局达成但未登机分别核对结算。 | +| 4 · 完整出击 | 把两种终局、三种机体风格、永久解锁、装备实物流转与临时强化重置串成可重复游玩的版本。 | 三类底盘各试首领与护送,两种视角均有完整战斗记录。记录完整通关用时,按 20–30 分钟目标调节。提前撤离、货舱发射后机毁、终局达成但未登机分别核对结算,并验证带出装备可在下一局携入。 | 先用占位模型和声音跑通上述流程,再把 Blender 导出物与合适的免费素材替换进已验证的场景。工程与素材约定见 [项目文件与素材规范](../Production/ProjectConventions.md)。 diff --git a/Docs/Production/EjectionSafeCaseAndGridPlan.md b/Docs/Production/EjectionSafeCaseAndGridPlan.md new file mode 100644 index 0000000..97c4c92 --- /dev/null +++ b/Docs/Production/EjectionSafeCaseAndGridPlan.md @@ -0,0 +1,139 @@ +# 弹射式安全箱与多格库存计划 + +2026-11-12。工作区:`D:/vibeCoding/unityRouge/workTree/tree1`;分支:`codex/models-weapons-equipment-animation`。 + +本计划扩展[装备系统计划](EquipmentSystemPlan.md),统一弹射保全、二维格子库存和机毁结算。首个可玩切片与实测范围见[装备验证](../QA/Equipment/DataFoundation.md)。 + +## 已确认的玩法与首测参数 + +用户已确认以下规则: + +- 增加**弹射式安全箱**。机体被摧毁时,安全箱自动发射回基地,保护箱内物品。 +- **任何放得下的未安装物品都能放入安全箱**,包括完整武器和机体模块。准入按装配状态与空间判断,不按物品类别限制。 +- 库存采用**二维格子**,物品有宽、高,可旋转,必须实际放进连续空位。 +- 安全箱是已确认的机毁损失例外:普通携带装备及货物仍会损失,基地库存与已经回收的物品保留。 + +以下容量和物品尺寸为首轮试玩建议值,可依据实际收益和背包操作调整,尚不是最终平衡数值。本阶段使用新建的 v2 存档。 + +| 容器 | 建议尺寸 | 使用方式 | +| --- | --- | --- | +| 普通货仓 | 4×4,16 格 | 携带战利品和卸下的装备,机毁损失。替代原先“最多 4 个条目”的容量规则。 | +| 主动货舱 | 2×2,4 格 | 每次出击 1 次,继续原地准备 3 秒后发射;按装舱布局校验,替代原先“至多 2 个条目”的选择限制。 | +| 弹射式安全箱 | 2×2,4 格 | 独立保护空间;机毁自动返库,正常撤离随整机回收。后续可配置更大型号。 | +| 基地库存 | 首轮沿用无硬上限的库存与收货列表 | 显示物品尺寸并支持整理;回收不能因基地摆不下而失败。以后增加有限网格仓库时提供待整理区。 | + +两种回收舱分别记录状态与次数。主动货舱发射不消耗安全箱,安全箱也不占主武器、副武装、推进器或防护槽。 + +## 多格物品与二维摆放 + +### 物品占格示例 + +| 物品 | 建议占格 | 说明 | +| --- | --- | --- | +| 合金、电芯材料堆 | 1×1 | 每堆最多 10 个,1–10 个仍占同一块空间;第 11 个需要另一个堆叠与空位。 | +| 蓝图 | 1×1 或 1×2 | 不同型号可以有不同尺寸,单张不可堆叠。 | +| 小型电子配件 | 1×1 或 1×2 | 作为后续配件内容的尺寸约定,首轮不要求先完成配件玩法。 | +| 紧凑副武器 | 1×2 | 可整体放入建议的 2×2 安全箱。 | +| 轻型主武器 | 1×3 | 占 3 格,但两个朝向都放不进 2×2 安全箱。 | +| 标准主武器 | 2×3 | 占 6 格,需要普通货仓或更大的安全箱。 | +| 重型主武器 | 2×4 | 占 8 格,以占用空间形成携带取舍。 | +| 推进器、防护模块 | 1×2 或 2×2 | 完整模块只要尺寸匹配即可放入安全箱。 | + +具体型号通过物品配置填写尺寸,不根据模型包围盒推算,也不通过名称或品质猜测。首轮使用矩形占格;附件或折叠状态以后若改变尺寸,需重新校验完整摆放,不能挤出或删除其他物品。 + +### 摆放与流转规则 + +1. 配置记录 `width`、`height`;摆放记录容器、格子坐标与朝向。旋转为 90°,宽高互换,面积不变。所有未安装矩形物品支持旋转。 +2. 放置需要每个覆盖格都在边界内且未被占用。既检查面积,也检查形状;总空格足够仍可能摆不下。 +3. 拖拽时显示物品轮廓和有效/无效位置,R 键旋转。保留点击选择物品再选择目标容器、键盘确认/取消的操作途径,避免必须依赖精确拖拽。 +4. 自动放置按稳定次序寻找连续空位,并提示采用的方向。自动放置不移动其他物品;整理只在玩家明确操作的容器内进行。 +5. 容器之间移动、交换与旋转全部先预检,再一次提交。失败、取消或保存失败时保持原位置和数量;交换时同时计算双方移出后的空位。 +6. 材料支持拆分、合并与部分转移。数量总和保持,拆分产生新堆叠 UID,合并退休被合并堆叠的 UID;装备实例 UID 始终不变。 +7. 已安装装备先卸下才进入货仓或安全箱。直接卸入安全箱时要在同一事务中清空装备槽并放入;放不下则整个动作失败。 +8. 局内允许卸件后出现空槽。运行时使用明确的无装备状态:空主/副武器槽不能射击,空推进器/防护槽只保留底盘固有能力,不以索引回退偷偷生成默认装备。 +9. 占格、物品数量、质量与额定功率分别定义。二维体积不直接换算载重;已安装装备继续遵守底盘配装预算。 + +## 弹射式安全箱的使用 + +- 每台机体出击时携带一个独立安全箱。首轮视为底盘的基础保全功能,箱体本身不作为可重复刷取的战利品;安全箱内容是有实际归属的库存物品。 +- 玩家可以在出击前或局内把物品放入安全箱,也可以取回普通货仓或安装。放入成功保存后即显示“机毁可保全”;拖拽预览和未完成转移不形成保护。 +- 所有普通未安装物品一视同仁,包括武器、模块、材料、蓝图。没有按价值、品质或装备类别设置的准入黑名单。容器尺寸、空位、当前归属与事务锁仍需有效。 +- 救济借用件若装入安全箱,返基地后归还救济池;保留其借用来源,不转换为个人永久装备或材料。该来源规则不改变普通物品的占格与准入方式。 +- 机体被摧毁时自动触发一次弹射,首轮可靠返基地,不设置额外按键、准备条、费用、成功概率或途中被击毁的判定。 +- 正常撤离时安全箱内容随整机一起入库,不额外再结算一次弹射。普通退出、切换战区、断开视角或手动关闭菜单都不触发返库。 +- 致命状态锁定后不能再塞物品、拆分、卸件或改摆放;弹射内容取自机毁时最后一份有效归属快照。 +- 空箱机毁可显示“无保全物品”,但仍将本次机毁与箱状态一并完成,重复回调不能再次产生收益。 + +安全箱内的高价值整件装备同样可保全,机毁的实际损失取决于玩家此前如何分配空间。容量升级的费用与获取方式在基础循环实测后设计,不改变准入原则。 + +## 三种回收方式与结算顺序 + +| 事件 | 回收范围 | 后续状态 | +| --- | --- | --- | +| 主动发射货舱 | 普通货仓中选定且可实际装进主动货舱的物品。允许拆分材料后再选取。 | 发射成功即入库,货舱已用;安全箱与机上其余物品仍属当前出击。 | +| 正常登机撤离 | 已安装装备、普通货仓与安全箱内容,按实际归属回收。 | 结束出击;不生成机毁弹射收益。 | +| 机体被摧毁 | 仅安全箱中已保存的物品自动弹射返库。 | 普通货仓、已安装装备和留在战区的物品损失,结束出击。此前主动送回的收益保留。 | +| 致命帧与货舱发射同时发生 | 先锁定致命状态;此前已经成功提交的主动回收保留,尚未提交的发射取消。 | 同一物品只按一次已完成的所有权转移处理。 | +| 写入失败 | 不提前改变物品归属,不播放返库成功。 | 锁定机毁现场并重试;从有效快照恢复后继续同一笔结算。 | + +建议为弹射保留独立事件键 `:secure_eject`,原 `:cargo` 与 `:return` 保持不变。具体新字段和事件常量随存档方案确认后冻结。 + +机毁流程按以下顺序实现: + +1. 识别并锁存机毁,关闭库存操作、换装、主动货舱提交和登机入口。`GameSession` 必须优先处理致命状态,避免当前“交互/货舱之后才检查生命”的顺序允许临终补放物品。 +2. 在候选快照记录待结算的毁坏状态及安全箱条目引用,冻结其内容与本次事件键。此步骤不将条目同时复制到基地;待结算记录不是第二个所有者。 +3. 计算完整机毁结果:安全箱内容转基地库存或收货区,清除箱内归属;记录弹射收据;清除普通出击物品;结束本次出击并记录既有 `:return` 收据。上述变更一次写入后共同生效。 +4. 成功保存后播放脱离、点火、返航效果和机毁报告;动画结束或取消都不再修改库存。报告分列“主动送回”“安全箱保全”“机毁损失”。 +5. 若最终写入失败,保留可重试的待结算状态,不能返回可自由操作的战区。重载发现待结算机毁或已保存的零装甲活动出击时,先恢复结算再开放输入。 + +待结算记录也无法写入时,不声称未落盘的机毁已经持久化;保持当前锁定状态重试,重启按上一份完整有效快照恢复。基地库存后来若引入硬容量,自动弹射物品进入待整理区,不因摆放失败而丢失,整理也不重新发放收益。 + +## 数据、迁移与界面安排 + +| 模块 | 扩展内容 | +| --- | --- | +| 物品配置 | 物品类型、基础长宽、堆叠上限和尺寸版本;装备型号 ID、实例 ID、蓝图 ID 保持稳定。 | +| 容器定义与运行记录 | 容器类型、宽高、物品摆放坐标与朝向;普通货仓、安全箱、主动装舱布局分别记录,避免共用一个物品列表。 | +| 网格服务 | 放置/移出/旋转/交换/拆并堆叠/自动摆放的预检与事务;每个格子的占用从权威摆放记录派生。 | +| 出击与存档 | 安全箱状态、物品唯一归属、待机毁结算记录与弹射收据;与既有回收、完整快照和备份逻辑共用一个提交入口。 | +| 装备与属性 | 支持空槽与按实例卸装;保存成功后才刷新属性、模型和预算显示。 | +| UI 与本地化 | 普通货仓/安全箱并列展示、物品尺寸、旋转与错误原因、可保全标记、装舱预览、保全/损失明细,完整支持中英文与键鼠。 | + +本轮按用户要求直接写入新 v2 档。恢复时校验唯一归属、坐标、堆叠上限、已装槽位及回收收据;损坏主文件可从完整备份恢复。旧版迁移延后。 + +UI 推荐同屏展示普通货仓与安全箱,并始终显示当前箱内内容的保护状态。主动货舱打开单独的装舱预览;选择数量不再限制为 2 个,最终以合法摆放为准。颜色之外提供文字与形状提示,拖拽或旋转不改变当前战斗菜单的输入交接规则。 + +## 实施顺序 + +| 编号 | 与装备计划的关系 | 交付与完成条件 | +| --- | --- | --- | +| S1 | E1 数据底座 | 冻结长宽、容器坐标、朝向、空槽、安全箱与待结算结构。 | +| S2 | E2/E3 前置 | 网格预检与转移、旋转、拆并堆叠;覆盖面积够但形状不符、交换和失败回滚,确保无复制或吞物。 | +| S3 | E3 搜刮换装 | 同屏普通货仓/安全箱 UI、卸入安全箱、拖拽旋转与替代键盘操作;跨区和重载保留布局、归属与保护状态。 | +| S4 | E4 回收结算 | 主动货舱改为按布局装载,加入机毁锁存、自动弹射、独立收据与可恢复的完整结算。 | +| S5 | E5/E6 展示 | 保全/损失报告、双语与布局回归;Blender MCP 制作弹射箱及挂点,Unity MCP 接入弹射效果,画面不承担库存结算。 | +| S6 | E7 验收 | Unity MCP 测试、编辑器回放、Windows 构建与实机操作,记录保护收益与空间选择,对首测容量进行平衡。 | + +先完成网格与唯一归属,再接安全箱 UI 和自动结算;第一轮可用占位效果验证弹射,不等待最终模型才验证数据行为。 + +## 必须覆盖的验收 + +| 场景 | 预期结果 | +| --- | --- | +| 2×2 箱尝试放入 1×3 武器 | 两个朝向都拒绝,虽然面积足够;物品保持原处。 | +| 1×2 武器放入仅有横向连续空位的区域 | 旋转后可放入,重载保留同一 UID、坐标和朝向。 | +| 未安装完整武器或 2×2 模块装入安全箱 | 合法尺寸即可成功,机毁后整体保全,不能按装备类别拒绝。 | +| 已安装武器直接卸入安全箱,成功/放不下/写入失败 | 成功时槽位为空且箱内独占该 UID;失败时原装备、属性、模型和箱布局均保持。 | +| 空格数足够但连续形状不足、越界或重叠 | 拒绝并显示具体原因,取消与拖出界面不丢物。 | +| 材料拆分、合并至上限,移动部分数量进入安全箱 | 各处数量总和守恒,超限部分明确保留;只保全实际放入数量。 | +| 已装满安全箱的正常撤离 | 内容只入库一次,没有额外弹射收益。 | +| 普通货仓有武器、安全箱有另一件武器后机毁 | 安全箱武器以原 UID 返库,普通武器损失;基地原库存保持。 | +| 主动货舱已发射后机毁 | 主动送回与安全箱保全各结算一次,剩余普通物品损失,两个舱互不消耗。 | +| 机毁同帧尝试塞箱、换装、发射、登机或重复机毁回调 | 已锁存机毁阻止后续操作,结果遵守提交顺序,不双结算、不临终补塞。 | +| 待结算快照重载、结算写入失败后重试、动画重复回调 | 使用同一事件键与物品归属重试,只形成一次基地收益。 | +| 普通退出、跨区、试验场机毁 | 不将未结算物品凭空送回;跨区保持箱布局;试验场收据与物品不进入正式档。 | +| 鼠标拖拽、R 旋转、键盘确认/取消、切换中英文 | 逻辑结果一致,错误说明与保护标记完整,物品位置和选中状态保持。 | + +原有“3 个条目超过两格货舱”的容量测试需改写为空间断言,不能让过时的条目数量限制继续影响新规则;重复选择、事件去重、写入失败和完整快照恢复覆盖继续保留。 + +归档沿用 `Docs/QA/Equipment/` 与 `Builds/Windows/x64/Equipment/`。按实际完成情况分别记录静态检查、Unity 编辑器测试、独立程序操作与性能采样;本轮设计文档不代表功能或测试已经交付。 diff --git a/Docs/Production/EquipmentSystemPlan.md b/Docs/Production/EquipmentSystemPlan.md new file mode 100644 index 0000000..7a2c568 --- /dev/null +++ b/Docs/Production/EquipmentSystemPlan.md @@ -0,0 +1,146 @@ +# 搜打撤装备系统扩展计划 + +2026-11-12。工作区:`D:/vibeCoding/unityRouge/workTree/tree1`;分支:`codex/models-weapons-equipment-animation`。 + +首个装备数据切片已接入运行时,验证记录见 [装备验证](../QA/Equipment/DataFoundation.md)。关联文档:[玩法设计](../Design/GameDesign.md)、[总实施计划](ImplementationPlan.md)、[文件规范](ProjectConventions.md)。 + +## 目标与决策状态 + +将现有的蓝图解锁与局内换件,扩展为装备实物循环:**机库配装 → 携装出击 → 搜索与换装 → 主动货舱/整机回收或机毁安全箱弹射 → 整理、制造与维修 → 再次出击**。每件装备有独立身份,上一局带出的那件武器可以成为下一局的带入装备。 + +2026-11-12,用户已确认:**装备实物可带出入库、下局带入;机毁时安全箱自动弹射回基地,箱内物品保全,箱外携带装备与未回收物资损失,机库库存保留。** 安全箱可装下任何尺寸合适的未安装物品,包括完整武器与机体模块;库存采用可旋转的二维格子。[玩法设计](../Design/GameDesign.md)已据此更新;局内临时强化仍在出击结束时重置。本轮使用独立 v2 存档。 + +| 决策 | 状态 | 方案与影响 | +| --- | --- | --- | +| 装备跨局归属与损失 | 用户已确认 | 实物装备可回收入库、下局带入;机毁保全安全箱内容,损失箱外装备和未回收货物。底盘解锁、蓝图和已入库物资继续保留。 | +| 弹射式安全箱 | 用户已确认 | 机毁自动发射返基地,任何放得下的未安装物品均可放入,包括完整武器与模块。 | +| 多格库存 | 用户已确认 | 物品有长宽,在二维格子中实际摆放,可旋转;容量不再只按物品条目数判断。 | +| 存档演进 | 用户已确认 | 本阶段从 v2 新档开始,正式档与试验场档分别保存。旧档迁移延后。 | + +具体容量、制造、救济装和后续配件等细则仍按推荐方案规划。弹射箱与格子规则详见[弹射式安全箱与多格库存计划](EjectionSafeCaseAndGridPlan.md),其 S1–S6 合入本计划首个可玩工作包。 + +## 开发前的工程基线 + +| 位置 | 已有能力 | 本次需要补齐 | +| --- | --- | --- | +| [EquipmentDefinition.cs](../../Assets/Game/Scripts/Equipment/EquipmentDefinition.cs) 与 `Assets/Game/Data/Equipment/` | 9 件静态定义;主武器、副武装、推进器、防护四槽;载重、功率、伤害、射速、速度系数等字段。 | 独立实物实例、内容索引、明确的装备效果和模型绑定。 | +| [GameSession.cs](../../Assets/Game/Scripts/Runs/GameSession.cs) | 初始配装、预算检查、三项蓝图研究、局内替换和跨战区状态。 | 真实库存扣取、旧件去向、实例归属;换装提交失败时不能应用外观或显示成功。当前 `Install` 没有处理提交返回值。 | +| [ProfileStore.cs](../../Assets/Game/Scripts/Save/ProfileStore.cs) | v1 快照、材料与蓝图、4 格货仓、2 格货舱、回收事件去重、备份与写入失败处理。 | 装备入库/携出/损失、容器余量、迁移与未知版本保护。当前装备是 `int[4]` 索引;`Bank` 会将合金、电芯以外的物品当作蓝图处理。 | +| [WarzoneDirector.cs](../../Assets/Game/Scripts/Runs/WarzoneDirector.cs) | 固定灰盒搜索点、搜索来源编号、部分箱体附带换件选项。 | 可保留剩余物品的容器、真实装备掉落与地面旧件。当前领取材料/蓝图与换件选项绑定。 | +| [MechPilot.cs](../../Assets/Game/Scripts/Mechs/MechPilot.cs) | 共用双视角命中、武器伤害与射速、推进器速度、防护减伤。 | 配装后的统一属性结果、实际装备模型和枪口挂点。当前枪口位置为代码偏移,`ApplyLoadout` 主要处理机体缩放。 | +| [GameHud.cs](../../Assets/Game/Scripts/UI/GameHud.cs) | 机库轮选配装、简单部件比较、货舱选择、研究与双语刷新。 | 实物库存、装备详情、明确的换装/丢弃/回收动作和收益明细。 | +| [ProfileStoreTests.cs](../../Assets/Tests/EditMode/ProfileStoreTests.cs) | 原有 6 项存档与回收测试。 | 保留去重与故障恢复覆盖,容量断言按二维规则更新;新增实例、迁移、格子与安全箱场景。已有验收记录不代表 tree1 已通过本轮测试。 | + +## 首个可玩版本的装备规则提案 + +### 1. 配装与装备价值 + +- 沿用四槽:主武器、副武装、推进器、防护模块。先复用现有 9 件定义,保存其 ID 与 GUID;底盘仍作为永久解锁的平台。 +- 同型号可拥有多件实物,分别用唯一 `instanceId` 区分。型号配置与物品实例分开,界面按物品实例进行携出、换装和回收。 +- 出击前从机库选择实物。开始出击的一次成功保存,将所选装备从机库转入本次出击;配装预览不转移所有权。 +- 蓝图研究解锁制造配方;制造消耗库存材料并产生一件实物。研究费用与制造费用分别配置,已研究不再等同于无限免费携装。 +- 新存档获得一次基础四槽装备。无可用配装时可使用绑定本次出击的低配救济装;借用件可以按尺寸放入容器和安全箱,返基地时归还救济池,不能转成个人永久库存或制造材料,避免资源循环套利。 +- 首轮直接比较伤害、射速、机动、防护、载重和功率。型号需要有明确取舍;更强火力也可能带来更高负担。 + +### 2. 搜索、存放与换装 + +- 物品按定义的宽高在二维容器中摆放,支持 90° 旋转。首测建议普通货仓 4×4、主动货舱 2×2、安全箱 2×2,具体数值可调;每次出击仍只有 1 次主动货舱发射。材料每堆最多 10 个,装备不可堆叠,不同装备可占多格。 +- 搜索箱显示实际物品,玩家逐件领取、比较或直接换装。领取后剩余内容仍保存在容器中,满仓不会消耗物品或锁死来源。 +- 换装时重新校验槽位兼容、载重、功率和旧件去向。旧件可进入货仓;没有位置时需明确选择交换回原容器或放到地面,不能静默删除。 +- 地面装备与容器剩余装备均有稳定实例 ID,跨存档重载保持;卸件、拾取、交换、丢弃均按一次完整事务提交。 +- 安装中的武器、推进器和防护模块参与额定功率与配装载重检查;携带的货物不消耗额定功率。普通货仓、安全箱及主动货舱分别校验边界、重叠与可用形状,货物重量影响机动列入后续平衡。 +- 可将装备直接卸入安全箱:校验并保存后清空槽位,不能通过默认索引重新生成装备。局内空槽采用明确的无武器/无模块行为。 +- 局内菜单沿用现有输入与暂停规则;打开装备界面交接战斗输入,关闭后需要重新按射击键,完整保留双视角操作。 + +### 3. 主动货舱、安全箱、撤离与机毁 + +| 事件 | 装备与货物归属 | +| --- | --- | +| 发射准备被移动、受击或松键打断 | 物品继续在机上,货舱可重试;首轮沿用 3 秒准备。 | +| 成功发射货舱 | 从普通货仓装入可实际放进主动货舱网格的未安装物品;扣货、入库、货舱已用、事件编号在同一快照中保存。安全箱状态不变,不能继续按“至多 2 个条目”限选。 | +| 运输机抵达但未登机 | 所有剩余装备和货物仍在本次出击;沿用 15 秒接应等待与主动登机。 | +| 主动登机 | 已安装装备、普通货仓和安全箱内容按实际归属回收,借用件归还救济池,结束出击;不额外结算一次弹射。 | +| 机毁 | 先锁存机毁,安全箱中已保存物品自动弹射返库;清除箱外携带和留在战区内的装备、货物。安全箱入库、机毁损失与结束出击共同提交;之前已送回的物品、永久解锁和终局战果保留。 | +| 写入失败 | 普通操作保留原归属并可重试;机毁则锁定待结算现场,不开放库存或战斗输入。不先移除物品、播放成功回收或应用新配装。 | +| 正常退出或重载当前出击 | 恢复已保存的装备、容器、地面物品、货舱与收据;退出不直接触发返库。 | + +货舱仍用既有 `:cargo`,整机结算仍用 `:return` 去重;安全箱拟用独立 `:secure_eject`,在具体存档方案中冻结。相同物品不会同时归属基地与安全箱,致命状态锁存后不能临时补塞物品。蓝图、材料、装备按明确类型分别入库,禁止继续依靠“其他物品就是蓝图”的分支。 + +### 4. 首轮内容与后续扩展 + +| 层级 | 交付内容 | +| --- | --- | +| 首个可玩切片 | 现有 9 件定义、四槽实物配装、机库库存、制造、二维货仓与旋转、多格物品、弹射式安全箱、旧件流转、主动货舱/撤离/机毁与旧档迁移;装备界面随各步骤提供基本操作。 | +| 模型与动作接入 | 先完成 1 把主武器及 1 件替换部件的独立模型与实际挂接,再覆盖现有 9 件;补充开火后坐、瞄准、推进和换装展示。 | +| 下一轮装备深度 | 装备耐久与维修;少量固定品质与型号变体;瞄准、枪口、供能类配件及兼容规则;基于完整出击收益调整制造消耗与掉落。 | +| 另行设计的扩展 | 有限弹药与装填、仓库扩容、额外机体槽、保险、交易及随机词条。各项需单独评估对经济、动作和存档的影响。 | + +首轮保留现有射击与能源规则,品质、配件、耐久不会一次性引入;核心循环通过后再逐项增加。 + +## 数据与实现安排 + +| 数据/模块 | 拟定职责 | +| --- | --- | +| `EquipmentDefinition` 与内容索引 | 保留稳定型号 ID 与槽位编号;明确蓝图/配方映射及效果,绑定展示资源。用内容索引查询,避免把数组顺序继续写进新存档。 | +| `EquipmentInstance` | 保存实物唯一 ID、型号 ID 与来源标记;耐久和配件随对应阶段新增,静态数值由定义读取。 | +| 机库、出击槽、普通货仓、安全箱、容器、地面记录 | 一个实例同时只属于一处。各处持有实例或稳定引用的具体序列化方式在 E1 冻结;归属只保留一个权威来源,界面列表由它派生。 | +| 物品条目与容器快照 | 显式区分材料、蓝图、装备;记录物品长宽及尺寸版本、容器大小、坐标、朝向、剩余条目和掉落结果。以出击/战区/来源组成稳定键,重载不会重新抽取或改变布局。 | +| `EquipmentService` / 网格与配装校验 | 在候选快照内处理制造、携出、换装、卸件、旋转、拆并堆叠和流转;统一校验唯一归属、摆放合法性、兼容与预算。支持局内空槽;先成功保存,再通知运行时与 UI。 | +| 有效属性与外观绑定 | 根据已安装定义计算属性结果;在配装变更时更新,战斗读取同一结果。模型/动作按定义挂接,枪口判定读取实际挂点并保留近墙检测。 | +| `ProfileStore` | 延续快照提交、备份与既有事件语义,接入布局迁移、待机毁结算、安全箱弹射与装备回收。玩法服务执行校验,存档层负责完整状态落盘。 | + +主要代码放在现有 `Scripts/Equipment/`、`Scripts/Save/`、`Scripts/Runs/`,物品与容器按职责进入 `Scripts/Loot/`。配置沿用 `Data/Equipment/`,未来掉落表进入 `Data/Loot/`。计划中的新路径在实际实施时建立。 + +装备 UI 消费查询结果与明确的装备操作接口,地图通过容器来源 ID 和掉落配置接入,便于界面与地图工作整合。避免把库存、掉落和展示继续集中追加到 `GameSession` 或 `GameHud`。 + +### 当前存档与恢复 + +正式档为 `profile-v2.json`,试验场档为 `range-v2.json`。编辑器在各自工程的 `UserSettings/Ironspore/` 下运行。物品 UID、格子坐标、朝向、已装槽位和收据随完整快照写入,机毁结算失败后使用同一出击编号重试。v1 迁移留待后续独立安排。 + +## 实施顺序与完成条件 + +| 编号 | 依赖 | 工作与交付 | 完成条件 | +| --- | --- | --- | --- | +| E0 | 跨局规则已确认 | 核对 tree1 完整路径,完成独立存档与 PlayerPrefs 隔离;用准确 `Name@hash` 绑定 Unity MCP,完成导入、编译并记录原测试基线。需要建模时另读 Blender 当前文件与场景。 | 目标工程和测试数据路径明确,原有测试实际通过,能够独立启动当前原型。 | +| E1 | E0 | 固定槽位/型号映射,建立装备实例、物品尺寸、二维容器、安全箱与待机毁记录、唯一归属校验,合入 S1/S2 并冻结字段清单。 | 非法索引、未知版本、重复实例、无效布局和写入失败均保留有效原状态。 | +| E2 | E1 | 机库实物库存、出击配装、一次性基础装与救济装、研究配方与制造;提供最小库存/配装 UI。 | 预览不扣库存;出击仅携出所选实例;制造正确扣料;不能从同一库存实例装两处或刷救济收益。 | +| E3 | E2 | 可持久化容器、二维摆放/旋转、逐件领取、比较换装、卸入安全箱及空槽行为,合入 S3;接入载重/功率与有效属性。 | 面积与形状均合法才转移;完整武器/模块可按尺寸保全;旧件始终有去向;重载保留布局,换装失败保持属性、模型和库存一致。 | +| E4 | E3 | 主动货舱改按实际摆放装载,扩展整机回收、机毁自动弹射、待结算恢复与报告明细,合入 S4,沿用既有结算编号。 | 同一实物带出后可在下一局再次带入;安全箱保全、普通损失与出击结束共同提交;重复货舱、弹射、登机或机毁回调不会增物。 | +| E5 | E2–E4 | 完成库存筛选、尺寸/旋转、装备详情、预算差值、制造材料、装舱预览与保全/损失报告,覆盖双语、焦点和快捷操作。 | 鼠标拖拽和键盘替代操作均可用;切换语言保持布局、选择和页面;1280×720、1920×1080、2560×1440 中关键操作可见、无溢出。 | +| E6 | E1 定义与挂点接口;联调依赖 E3 | Blender MCP 制作可拆换的模型,沿用 `Mount_Primary`、`Mount_Secondary`、`View_Cockpit`,约定新增推进/防护挂点;Unity MCP 导入组装并连接动作。 | 先验证一把武器和一个替换件;挂载、枪口、碰撞、第一人称视野、后坐与命中一致,重复导入保持 GUID 与引用。 | +| E7 | E4、E5、E6 | 通过 Unity MCP 完成 EditMode、编辑器回放与 Windows x64 构建,轮询任务结束;启动完整产物目录试玩并归档。 | 下方验收用例实际通过,Console 与报告可查;完整出击与单独实例性能采样有实测记录。 | + +第一个开发工作包按 **E0 → E1 → E2 → E3 → E4** 推进,各步附带必要 UI。E5 完成界面回归,E6 完成模型与动作;二者通过后进入 E7。完成首个切片后,再安排耐久、配件与经济扩展的独立工作包。 + +安全箱箱体、弹射挂点、脱离与返航效果随 E6/S5 制作,库存提交由数据层负责,动画回调不发放收益。S6 与 E7 共用测试、构建和试玩归档。 + +## 模型与动作交付约定 + +- Blender 源文件进入 `SourceAssets/Blender/Mechs/` 或相应装备子目录,导出进入 `Assets/Game/Art/Models/`;源、导出物、预制体与定义使用可追溯的资产编号。 +- 保留既有机体与挂点名称。装备模型单独挂接到项目预制体,重复导出检查单位、朝向、原点和枪口,避免更换武器时重做整台机甲。 +- 首轮动作覆盖待机/移动中的开火与瞄准、后坐恢复、推进反馈及换装后外观更新。两种视角读取同一次开火事件和枪口,动作不另行决定伤害。 +- 在现有试验场及战区中逐件替换和验证。独立模型、挂点、动画、材质与必要音效按[素材台账](AssetSources.md)登记来源;下载内容保留许可。 + +## 验收用例与归档 + +| 场景 | 必须观察到的结果 | +| --- | --- | +| 同型号两件、同一实例重复选择 | 两件独立保存并显示;同一实例不能装入两个位置。 | +| 携装出击后重载/继续出击 | 机库不保留同一件实物,活动出击持有它,继续出击不再次扣取。 | +| 出击开始、制造、换装、拾取或回收写入失败 | 材料、装备、来源、收据与运行时属性仍为操作前的完整状态,可安全重试。 | +| 满仓换武器、取消换装、原地丢弃并拾回 | 玩家明确选择旧件去向;实例 ID 不变,无删除、复制或满仓吞物。 | +| 部分搜取后重载、重复打开同一容器 | 只显示剩余物品;型号、数量、实例 ID 保持一致。 | +| 送回装备后机毁 | 已主动送回装备在库,安全箱内容自动保全;箱外已装与剩余货物损失,各自只结算一次。 | +| 运输机抵达但未登机,随后登机 | 到场时不入库,主动登机后才将已装装备与剩余货物一起入库。 | +| 终局完成但未登机,随后机毁 | 战果保留,安全箱物品保全,箱外未回收装备和物资损失。 | +| 一件装备完成“出击→回收→再次出击” | UID 持续相同;每个检查点恰好处于一个位置。 | +| 救济装拆解、转移、安全箱保全或普通回收 | 借用来源保持,返基地归还救济池,各入口不能产生个人永久物品或资源;正常实物仍按规则带出。 | +| 多格摆放、旋转与安全箱完整保全 | 几何空间合法才放置;未安装完整武器/模块不按类别拒绝,机毁按原 UID 保全。详见 S1–S6 验收。 | +| 载重/功率超限、试验场退出、语言反复切换 | 预算提示准确;正式库存不被试验场修改;页面上下文、提示与已有报告正确刷新。 | +| 两种视角换装与近墙射击 | 枪口与命中保持一致,装备不遮挡关键视野;菜单关闭及失焦恢复后不误开火。 | + +自动化重点为数据归属、状态流转、迁移、去重与写入失败,放入 `Assets/Tests/EditMode/`;通过 Unity MCP 定向运行并查询 job 到完成。UI、双视角和动作在编辑器与独立程序中分别实际核对。 + +阶段记录进入 `Docs/QA/Equipment/`,构建进入 `Builds/Windows/x64/Equipment/`。记录区分静态检查、编辑器测试和独立程序实测。60 FPS 按 1080p、中等画质、指定测试机与 P95 帧耗时目标采样;单局 20–30 分钟继续通过完整出击记录验收。 + +已执行的用例和剩余工作以本阶段验证记录为准。 diff --git a/Docs/Production/ImplementationPlan.md b/Docs/Production/ImplementationPlan.md index ef296b1..8a9767b 100644 --- a/Docs/Production/ImplementationPlan.md +++ b/Docs/Production/ImplementationPlan.md @@ -4,7 +4,9 @@ ## 工程起点 -tree1–tree3 的普通目录与资源副本已按 [资源初始化计划](TreeInitializationPlan.md) 完成,结果见 [初始化验收](../QA/Workspaces/Initialization.md)。三个目录暂为普通资源副本,未创建分支或注册 worktree。 +tree1–tree3 最初按 [资源初始化计划](TreeInitializationPlan.md) 建立普通资源副本,初始化阶段未创建分支或注册 worktree,结果见 [初始化验收](../QA/Workspaces/Initialization.md)。`tree1` 后续已在 `D:/vibeCoding/unityRouge/workTree/tree1` 注册独立工作区,分支为 `codex/models-weapons-equipment-animation`,用于玩家机甲模型、武器、动作与装备系统改善。 + +2026-11-12 增补的[搜打撤装备系统扩展计划](EquipmentSystemPlan.md)梳理实物库存、携装出击、搜索换装、回收与损失、存档迁移及模型动作交接,按 E0–E7 分步实施。用户已确认实物可带出并再次携入,并增补[弹射式安全箱与多格库存计划](EjectionSafeCaseAndGridPlan.md):机毁时安全箱自动返基地,任何放得下的未安装物品均可保全,箱外物品损失、机库库存保留;容器采用可旋转的二维网格。S1–S6 合入首个装备工作包,玩法设计已同步;当前原型已接入实物库存、安全箱与网格,按用户要求以 v2 新档开发。已测范围见[装备验证](../QA/Equipment/DataFoundation.md)。 2026-11-12 增补的主页语言切换及全局双语工作按 [多语言实施计划](LocalizationPlan.md) 的 L1–L7 执行,完成后继续原有美术、实战与性能工作。 @@ -89,23 +91,23 @@ Unity 工具名已对照工程内插件源码核查。场景使用 manage_scene | 编号 | 依赖 | 工作与文件产出 | 完成条件 | | --- | --- | --- | --- | -| 2.1 | 1.4 | Loot 下的物品定义、搜索交互、随身货仓和货物选择界面。固定样本为一张蓝图、合金和电芯。 | 搜索后可选取物资。随身 4 格、材料每组 10 个作为首测值。超出格数或堆叠上限时明确显示余量。 | +| 2.1 | 1.4;二维扩展按 S1–S3 | Loot 下的物品定义、长宽与旋转、搜索交互、普通货仓/安全箱和货物选择界面。固定样本为蓝图、合金、电芯与多格装备。 | 搜索后可摆放物资。材料每堆最多 10 个;容量按边界、连续形状和重叠检查,超过堆叠上限或摆不下时明确说明。 | | 2.2 | 2.1 | Save 下的版本化存档,Runs 下的出击状态,一次性入库逻辑和基础机库库存页。 | 同一次货舱或整机结算重试两遍,机库只增加一次。写入失败时保留原状态,玩家可重试。 | -| 2.3 | 2.2 | Extraction 下的货物回收舱,选择至多 2 格,原地按住 G 准备发射。 | 每次出击仅 1 舱。移动、松键或受击时可中断,舱与物资保留。成功升空时扣货、入库、标记舱已用一起保存。 | +| 2.3 | 2.2;网格回收与安全箱按 S4 | Extraction 下的主动回收舱,按二维布局装舱,原地按住 G 准备发射;机毁另行触发安全箱自动弹射。 | 每次出击仅 1 次主动货舱发射。主动准备可中断并保留物资;成功后共同保存扣货、入库和已用。安全箱不占该次数,机毁按已保存的箱内归属保全。 | | 2.4 | 2.2 | 本区撤离点、接应倒计时、运输机占位体和主动登机交互。 | 任意任务进度均可到点呼叫,等待期间能移动作战。运输机到达后再次按 E 登机才结算。 | | 2.5 | 2.3、2.4 | WarzonePrototype.unity,含一项推进目标、搜索物、一个守卫和下一站出口。 | 完成目标开放出口。目标完成前可以搜物资、发射货舱或整机撤离。每次成功返航在基础机库库存页查看所得。 | | 2.6 | 2.5 | Windows 单战区构建,结算逻辑测试与实机操作记录。 | 跑完下方「关键结算验证」。重开游戏后,机库物资、舱使用状态、机毁损失符合记录。 | -这一阶段的重点样例是「搜到蓝图 → 货舱送回 → 机甲被击毁 → 回机库看到蓝图,机上其余物资丢失」。 +这一阶段的重点样例是「主动货舱先送回蓝图 → 另一件装备放进安全箱 → 机甲被击毁 → 回机库看到蓝图与弹射保全装备,箱外其余物资丢失」。 ## 3 · 配装与分支出击 | 编号 | 依赖 | 工作与文件产出 | 完成条件 | | --- | --- | --- | --- | | 3.1 | 2.6 | Equipment 下的四槽装配、载重与额定功率检查,局内候选部件比较与替换。 | 替换推进器或防护模块,下一场立即体验到动作差异。装配超预算时能看见原因,三种底盘共用这套规则。 | -| 3.2 | 3.1 | Hangar.unity、蓝图解锁、材料消耗与初始配装方案。 | 蓝图和材料来自实际回收。解锁后可在下次出击初装,开始新出击时重置当局装备、强化与货舱次数。 | +| 3.2 | 3.1;实物库存按 E1–E4 扩展 | Hangar.unity、机库实物库存、蓝图研究、材料消耗与初始配装方案。 | 蓝图和材料来自实际回收,初始配装引用可用装备实例。成功带出的装备可再次携入,机毁保全安全箱、损失箱外物品;新出击保留机库库存与解锁,重置临时强化与货舱次数。 | | 3.3 | 3.2 | Runs 下的路线生成、节点情报、普通目标与中段终局分流。 | 地图公开任务与物资类别,具体战利品入场才发现。中段选首领或护送方向,任务完成后可继续搜再进下一站。 | -| 3.4 | 3.3 | 战区切换与当前出击存档,三个普通战区布局及轮换任务。 | 进入下一站保留机体状态、当局装备、随身物资和已用货舱标记。每站都能呼叫整机撤离。 | +| 3.4 | 3.3 | 战区切换与当前出击存档,三个普通战区布局及轮换任务。 | 进入下一站保留机体状态、当局装备、普通货仓与安全箱布局、已用主动货舱标记。每站都能呼叫整机撤离。 | ## 4 · 完整试玩版 @@ -123,24 +125,27 @@ Unity 工具名已对照工程内插件源码核查。场景使用 manage_scene | 数据 | 谁持有 | 何时保存或变化 | | --- | --- | --- | | 底盘、部件、战区定义 | Data 下的配置资产 | 制作阶段调整,以内容编号关联。 | -| 永久解锁、机库材料和配装方案 | 玩家存档 | 解锁或成功回收时提交。 | -| 出击编号、随机种子、当前节点、机体状态、当局装备和货仓 | 当前出击 | 出击开始、进入战区、成功回收、返航或机毁时提交对应状态。 | +| 永久解锁、机库材料、装备实物和配装方案 | 玩家存档 | 解锁、制造、携装出击或成功回收时提交,保存唯一归属。 | +| 出击编号、随机种子、当前节点、机体状态、当局装备、普通货仓和安全箱布局 | 当前出击 | 出击开始、合法流转/摆放、进入战区、成功回收、返航或机毁时提交对应状态。 | | 舱可用/准备中/已用 | 当前出击中的货舱记录 | 准备中锁住所选货物,成功发射后提交已用。加载一个发射前的完整快照时,从可重试状态恢复。 | +| 安全箱携带中/已弹射/随整机返回,机毁待结算记录 | 当前出击 | 致命状态锁存后冻结库存输入;安全箱返库、箱外损失、弹射收据与出击结束共同提交。正常登机只回收一次,跨区不重置箱内容。 | | 接应未呼叫/等待中/可登机 | 当前战区 | E 呼叫后进入等待。可登机时玩家主动交互返航。进入下一战区后,由该区自己的撤离点接应。 | | 终局达成与物资返航 | 两类独立事件记录 | 首领或护送目标达成时记战果,登机时结算机上可回收物资。 | -货舱和整机结算都先校验物资、机体状态与事件编号,在新快照中计算扣货与入库。写入成功后采用新状态并显示完成。损坏的临时文件从上一份完整备份恢复,相关流程用针对性的存档测试覆盖。 +主动货舱、整机回收和安全箱结算都先校验物资、布局、机体状态与事件编号,在新快照中计算归属转移。写入成功后采用新状态并显示完成,动画不发放库存。机毁写入失败时保持锁定并重试待结算记录;损坏文件从上一份完整备份恢复,相关流程用针对性的存档测试覆盖。 ## 关键结算验证 | 操作 | 预期结果 | 首次验收 | | --- | --- | --- | -| 选择 3 张蓝图装入 2 格舱 | 可选择至多 2 张,第三张留在机甲上。 | 2.3 | +| 将 1×3 物品装入 2×2 舱,或多件物品争用同一格 | 两个朝向均不适配或发生重叠时拒绝;不按物品件数替代几何校验。 | 2.3、S2/S4 | | 发射准备期间松键、移动或受击 | 原有物资仍在随身清单,货舱可再次使用。 | 2.3 | -| 发射成功后机甲被击毁 | 已回收货物在机库,机上货物丢失。 | 2.6 | +| 发射成功后机甲被击毁 | 已主动回收货物在机库,安全箱内容弹射保全,箱外机上货物损失。 | 2.6、S4 | +| 安全箱放入未安装完整武器或模块,随后机毁 | 尺寸合法即可装入,箱内装备按原 UID 弹射入库,箱外装备损失,机库原库存保留。 | E4、S4 | +| 已保存安全箱布局后跨区、正常撤离或待机毁结算重载 | 跨区保留摆放,正常撤离不再发放弹射收益,待机毁结算使用同一事件键完成一次。 | E4、S4/S6 | | 已用货舱后进入下一站,再打开货仓 | 舱保持已用,格位与机库物资正确。 | 3.4 | | 运输机到达但玩家尚未登机 | 玩家仍可操作,随身货物仍由机甲携带。 | 2.4 | -| 已完成终局但尚未登机,随后机毁 | 终局战果保留,未送回的随身物资丢失。 | 4.1 | +| 已完成终局但尚未登机,随后机毁 | 终局战果保留,安全箱物资保全,箱外未送回的随身物资丢失。 | 4.1、S4 | | 同一事件回调重复、保存后重开游戏 | 同一份货物只结算一次。货舱回收后还可正常结算整机返航。 | 2.6 | | 结算写入失败或临时快照损坏 | 可从有效状态恢复,背包、机库与结算标记相互一致。 | 2.6 | diff --git a/Docs/QA/Equipment/DataFoundation.md b/Docs/QA/Equipment/DataFoundation.md new file mode 100644 index 0000000..8680db4 --- /dev/null +++ b/Docs/QA/Equipment/DataFoundation.md @@ -0,0 +1,21 @@ +# 装备与弹射式安全箱验证 + +2026-11-12,Unity 6000.6.8f1,工作区 `tree1`,分支 `codex/models-weapons-equipment-animation`。 + +## 数据与编辑器测试 + +Unity MCP EditMode 作业 `304ef548c87d4fc6b6643e93b6c4588d` 通过 25/25 项。覆盖货舱形状与数量、装备与材料在容器间的归属、旋转和拆并、制造费用、同型号实例选择、借用件归还、机毁锁定、备份和写入失败重试。机毁后仅安全箱和此前主动送回的物品保全。首次运行发现动态字体图集为空,启动时预载翻译用字后,双语键、参数和字形测试通过。 + +## 编辑器试玩 + +- 从 Bootstrap 进入独立试验场。用 UI Toolkit 导航提交选择已装副武器,旋转并放入安全箱第二行,检查槽位为空、朝向与 UID。随后触发致命伤害,自动返回机库。试验场重新读取到同 UID 的一件装备,以及各一条弹射和返航收据。正式档未写入。 +- 在 1920×1080 中文界面通过指针事件把完整副武器从货仓拖进安全箱,再通过选择、旋转和目标格提交横放。普通货仓的合金与箱内武器在正常返航后均可查到。[界面截图](Grid-zh-1920x1080.png) +- 切换 1280×720 英文界面后,保留所选物品和旋转状态。读取到 20 个格子按钮,按钮中心均在面板内。编辑器退出播放后恢复原来的未命名场景,清理本次生成的试验场临时存档。 + +## Windows x64 + +Unity MCP 构建作业 `build-00b68c9a5c` 成功,0 错误、0 警告。产物目录为 `Builds/Windows/x64/Equipment/`。从该目录启动 Ironspore.exe,确认 Unity 创建 1280×720 客户区、初始化 D3D12 和输入系统,并响应关闭消息正常退出。独立程序本轮验证到启动和退出;上述装备操作在编辑器内验证。 + +## 后续验收 + +逐件搜取及地面丢弃回拾、弹射箱模型与飞行动画、双视角武器动作、完整独立程序出击和性能采样仍待实施或验证。当前默认容量为货仓 4×4、安全箱与主动货舱各 2×2。装备制造使用试玩费用;低配借用件返航后归还。 diff --git a/Docs/QA/Equipment/Grid-zh-1920x1080.png b/Docs/QA/Equipment/Grid-zh-1920x1080.png new file mode 100644 index 0000000..92c764c Binary files /dev/null and b/Docs/QA/Equipment/Grid-zh-1920x1080.png differ