feat(equipment): persist mech loadouts and recover ejection safe case loot

This commit is contained in:
2026-11-12 18:02:26 +08:00
parent 0857e291d5
commit e1d38c5a6d
23 changed files with 1371 additions and 204 deletions
+2 -2
View File
@@ -23,7 +23,7 @@ namespace Ironspore.Editor
static int[] initialEquipment;
static float nextAt;
static VisualElement Root => game.Hud.GetComponent<UIDocument>().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");
+58 -10
View File
@@ -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"
}
]
}
+58 -10
View File
@@ -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}"
}
]
}
@@ -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<CargoStack> 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<CargoStack> 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<CargoStack> 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<CargoStack> selection)
{
if (selection.Count == 0 || selection.Count > PodSize * PodSize) return false;
return Pack(0, new List<CargoStack>());
bool Pack(int index, List<CargoStack> 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;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2e9cb0228d5ccb643a654d0e82090d2a
@@ -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<char>();
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<string, string> ReadTable(TextAsset source)
{
if (!source)
+6 -5
View File
@@ -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;
}
+61 -35
View File
@@ -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<string>(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;
@@ -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<CargoStack> 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<string>();
var slots = new HashSet<int>();
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<string>(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<int[], bool> 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<CargoStack> Manifest(RunRecord r, IReadOnlyCollection<string> selection)
{
if (!Live(r) || r.podUsed || selection == null || selection.Count == 0 || selection.Count > 4) return null;
var ids = new HashSet<string>(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<string> selection) => Manifest(Data.run, selection) != null;
public bool SendCargo(IReadOnlyCollection<string> 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;
});
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 988a620c32c371a4da682504221e3651
+58 -98
View File
@@ -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<CargoStack> cargo = new List<CargoStack>();
public List<CargoStack> safeCase = new List<CargoStack>();
public List<CargoStack> installed = new List<CargoStack>();
public bool destructionPending;
public List<string> searched = new List<string>();
}
[Serializable]
public sealed class GameSave
{
public int version = 1;
public const int CurrentVersion = 2;
public int version = CurrentVersion;
public List<CargoStack> warehouse = new List<CargoStack>();
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<GameSave>(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<GameSave, bool> change)
{
if (Data.run.destructionPending) return false;
return Write(change);
}
bool Write(Func<GameSave, bool> change)
{
Error = null;
var candidate = JsonUtility.FromJson<GameSave>(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<string> selection)
{
if (selection == null || selection.Count == 0 || selection.Count > 2)
return false;
var ids = new HashSet<string>(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 =>
+25 -17
View File
@@ -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<PointerMoveEvent>(DragMove, TrickleDown.TrickleDown);
root.RegisterCallback<PointerUpEvent>(DragEnd, TrickleDown.TrickleDown);
root.RegisterCallback<PointerCancelEvent>(_ => ResetDrag(), TrickleDown.TrickleDown);
root.RegisterCallback<KeyDownEvent>(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;
}
+241
View File
@@ -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<CargoStack> 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<PointerMoveEvent>(DragMove, TrickleDown.TrickleDown);
button.RegisterCallback<PointerUpEvent>(DragEnd, TrickleDown.TrickleDown);
button.RegisterCallback<PointerDownEvent>(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();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6faff651d0fe95448b2711a92bce81d3
+6
View File
@@ -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); }
+1 -1
View File
@@ -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()
+123 -3
View File
@@ -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()
{
+15 -11
View File
@@ -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)。
@@ -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. 占格、物品数量、质量与额定功率分别定义。二维体积不直接换算载重;已安装装备继续遵守底盘配装预算。
## 弹射式安全箱的使用
- 每台机体出击时携带一个独立安全箱。首轮视为底盘的基础保全功能,箱体本身不作为可重复刷取的战利品;安全箱内容是有实际归属的库存物品。
- 玩家可以在出击前或局内把物品放入安全箱,也可以取回普通货仓或安装。放入成功保存后即显示“机毁可保全”;拖拽预览和未完成转移不形成保护。
- 所有普通未安装物品一视同仁,包括武器、模块、材料、蓝图。没有按价值、品质或装备类别设置的准入黑名单。容器尺寸、空位、当前归属与事务锁仍需有效。
- 救济借用件若装入安全箱,返基地后归还救济池;保留其借用来源,不转换为个人永久装备或材料。该来源规则不改变普通物品的占格与准入方式。
- 机体被摧毁时自动触发一次弹射,首轮可靠返基地,不设置额外按键、准备条、费用、成功概率或途中被击毁的判定。
- 正常撤离时安全箱内容随整机一起入库,不额外再结算一次弹射。普通退出、切换战区、断开视角或手动关闭菜单都不触发返库。
- 致命状态锁定后不能再塞物品、拆分、卸件或改摆放;弹射内容取自机毁时最后一份有效归属快照。
- 空箱机毁可显示“无保全物品”,但仍将本次机毁与箱状态一并完成,重复回调不能再次产生收益。
安全箱内的高价值整件装备同样可保全,机毁的实际损失取决于玩家此前如何分配空间。容量升级的费用与获取方式在基础循环实测后设计,不改变准入原则。
## 三种回收方式与结算顺序
| 事件 | 回收范围 | 后续状态 |
| --- | --- | --- |
| 主动发射货舱 | 普通货仓中选定且可实际装进主动货舱的物品。允许拆分材料后再选取。 | 发射成功即入库,货舱已用;安全箱与机上其余物品仍属当前出击。 |
| 正常登机撤离 | 已安装装备、普通货仓与安全箱内容,按实际归属回收。 | 结束出击;不生成机毁弹射收益。 |
| 机体被摧毁 | 仅安全箱中已保存的物品自动弹射返库。 | 普通货仓、已安装装备和留在战区的物品损失,结束出击。此前主动送回的收益保留。 |
| 致命帧与货舱发射同时发生 | 先锁定致命状态;此前已经成功提交的主动回收保留,尚未提交的发射取消。 | 同一物品只按一次已完成的所有权转移处理。 |
| 写入失败 | 不提前改变物品归属,不播放返库成功。 | 锁定机毁现场并重试;从有效快照恢复后继续同一笔结算。 |
建议为弹射保留独立事件键 `<runId>:secure_eject`,原 `<runId>:cargo` 与 `<runId>: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 编辑器测试、独立程序操作与性能采样;本轮设计文档不代表功能或测试已经交付。
+146
View File
@@ -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 秒接应等待与主动登机。 |
| 主动登机 | 已安装装备、普通货仓和安全箱内容按实际归属回收,借用件归还救济池,结束出击;不额外结算一次弹射。 |
| 机毁 | 先锁存机毁,安全箱中已保存物品自动弹射返库;清除箱外携带和留在战区内的装备、货物。安全箱入库、机毁损失与结束出击共同提交;之前已送回的物品、永久解锁和终局战果保留。 |
| 写入失败 | 普通操作保留原归属并可重试;机毁则锁定待结算现场,不开放库存或战斗输入。不先移除物品、播放成功回收或应用新配装。 |
| 正常退出或重载当前出击 | 恢复已保存的装备、容器、地面物品、货舱与收据;退出不直接触发返库。 |
货舱仍用既有 `<runId>:cargo`,整机结算仍用 `<runId>:return` 去重;安全箱拟用独立 `<runId>: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 分钟继续通过完整出击记录验收。
已执行的用例和剩余工作以本阶段验证记录为准。
+17 -12
View File
@@ -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 |
+21
View File
@@ -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。装备制造使用试玩费用;低配借用件返航后归还。
Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB