122 changed files with 108460 additions and 462 deletions
+1
View File
@@ -33,3 +33,4 @@ __pycache__/
/agents.md.txt
.DS_Store
Thumbs.db
/Docs/QA/**/PlayerData/
+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]
@@ -98,7 +98,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");
+7 -2
View File
@@ -68,6 +68,11 @@ namespace Ironspore.Editor
p.mass = masses[i];
p.power = powers[i];
p.damage = i == 0 ? 15 : i == 1 ? 47 : i == 2 ? 90 : 65;
if (i < 3)
{
p.magazineSize = new[] { 36, 12, 6 }[i];
p.reloadSeconds = new[] { 2f, 1.8f, 2.6f }[i];
}
p.fireRate = i == 0 ? 7 : i == 1 ? 1.7f : 1;
p.guided = i == 3;
p.speedFactor = i == 6 ? 1.2f : 1;
@@ -119,8 +124,8 @@ namespace Ironspore.Editor
actions.AddActionMap(map);
map.AddAction("Move", InputActionType.Value).AddCompositeBinding("2DVector").With("Up", "<Keyboard>/w").With("Down", "<Keyboard>/s").With("Left", "<Keyboard>/a").With("Right", "<Keyboard>/d");
map.AddAction("Look", InputActionType.Value, "<Mouse>/delta");
string[] actionNames = { "Fire", "Aim", "Boost", "Jump", "View", "Interact", "Inventory", "Cargo", "Pause", "Secondary", "Guard" };
string[] bindings = { "<Mouse>/leftButton", "<Mouse>/rightButton", "<Keyboard>/leftShift", "<Keyboard>/space", "<Keyboard>/v", "<Keyboard>/e", "<Keyboard>/tab", "<Keyboard>/g", "<Keyboard>/escape", "<Keyboard>/q", "<Keyboard>/f" };
string[] actionNames = { "Fire", "Aim", "Boost", "Jump", "View", "Interact", "Inventory", "Cargo", "Pause", "Secondary", "Guard", "Reload" };
string[] bindings = { "<Mouse>/leftButton", "<Mouse>/rightButton", "<Keyboard>/leftShift", "<Keyboard>/space", "<Keyboard>/v", "<Keyboard>/e", "<Keyboard>/tab", "<Keyboard>/g", "<Keyboard>/escape", "<Keyboard>/q", "<Keyboard>/f", "<Keyboard>/r" };
for (int i = 0; i < actionNames.Length; i++)
map.AddAction(actionNames[i], InputActionType.Button, bindings[i]);
File.WriteAllText("Assets/Game/Input/GameInput.inputactions", actions.ToJson());
+2
View File
@@ -18,6 +18,8 @@ MonoBehaviour:
mass: 6
power: 6
damage: 47
magazineSize: 12
reloadSeconds: 1.8
fireRate: 1.7
speedFactor: 1
guided: 0
+2
View File
@@ -18,6 +18,8 @@ MonoBehaviour:
mass: 4
power: 3
damage: 15
magazineSize: 36
reloadSeconds: 2
fireRate: 7
speedFactor: 1
guided: 0
+2
View File
@@ -18,6 +18,8 @@ MonoBehaviour:
mass: 9
power: 4
damage: 90
magazineSize: 6
reloadSeconds: 2.6
fireRate: 1
speedFactor: 1
guided: 0
+21
View File
@@ -135,6 +135,16 @@
"interactions": "",
"initialStateCheck": false,
"priority": 0
},
{
"name": "Reload",
"type": "Button",
"id": "bb99b681-9cce-48de-8c4f-4c34ebbd42ea",
"expectedControlType": "",
"processors": "",
"interactions": "",
"initialStateCheck": false,
"priority": 0
}
],
"bindings": [
@@ -324,6 +334,17 @@
"action": "Guard",
"isComposite": false,
"isPartOfComposite": false
},
{
"name": "",
"id": "2b1a88d9-c0ce-4c90-93c3-5b7114de2917",
"path": "<Keyboard>/r",
"interactions": "",
"processors": "",
"groups": "",
"action": "Reload",
"isComposite": false,
"isPartOfComposite": false
}
]
}
+397 -25
View File
@@ -46,19 +46,19 @@
},
{
"key": "range.recoveryGuide",
"value": "Search a case [E], select cargo [Tab], then stop and hold [G] to send."
"value": "Search a case [{0}], select cargo [{1}], then stop and hold [{2}] to send."
},
{
"key": "range.recoverySent",
"value": "Cargo sent. Call the transport at the orange pad [E]."
"value": "Cargo sent. Call the transport at the orange pad [{0}]."
},
{
"key": "range.board",
"value": "Transport ready. Board at the orange pad [E]."
"value": "Transport ready. Board at the orange pad [{0}]."
},
{
"key": "range.reset",
"value": "Reset drills [E]"
"value": "Reset drills [{0}]"
},
{
"key": "range.gate",
@@ -118,15 +118,15 @@
},
{
"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",
"value": "WASD move / mouse aim / V perspective / Shift boost / Space jump & lift"
"value": "{0} move / mouse aim / {1} perspective / {2} boost / {3} jump & lift"
},
{
"key": "ui.home.fireHint",
"value": "LMB primary / RMB precision aim / Q secondary / F shield / E interact"
"value": "{0} primary / {1} precision aim / {2} secondary / {3} shield / {4} interact"
},
{
"key": "ui.home.inventory",
@@ -186,11 +186,11 @@
},
{
"key": "ui.hud.podReady",
"value": "POD READY / SELECT IN TAB"
"value": "POD READY / {0} SELECT CARGO"
},
{
"key": "ui.hud.hint",
"value": "V VIEW SHIFT BOOST SPACE LIFT Q SECONDARY F SHIELD E INTERACT TAB CARGO HOLD G SEND POD ESC PAUSE"
"value": "{0} VIEW {1} BOOST {2} LIFT {3} SECONDARY {4} SHIELD {5} INTERACT {6} CARGO HOLD {7} SEND POD {8} PAUSE"
},
{
"key": "ui.hud.zone",
@@ -230,7 +230,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",
@@ -246,15 +246,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 {0} while stationary for three seconds to launch the pod."
},
{
"key": "ui.cargo.stack",
@@ -266,7 +266,7 @@
},
{
"key": "ui.cargo.capacity",
"value": "HOLD {0} / 4 POD {1} / 2"
"value": "Hold {0}/16 cells Pod {1}/4 cells"
},
{
"key": "ui.return",
@@ -362,7 +362,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",
@@ -370,7 +370,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",
@@ -382,7 +382,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",
@@ -390,7 +390,7 @@
},
{
"key": "notice.range",
"value": "DRIVING RANGE / V switches perspective. E operates nearby devices."
"value": "DRIVING RANGE / {0} switches perspective. {1} operates nearby devices."
},
{
"key": "notice.enter",
@@ -406,7 +406,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",
@@ -462,7 +462,7 @@
},
{
"key": "notice.transportReady",
"value": "TRANSPORT READY / return to the orange pad and press E to board."
"value": "TRANSPORT READY / return to the orange pad and press {0} to board."
},
{
"key": "notice.escortLost",
@@ -470,7 +470,7 @@
},
{
"key": "prompt.search",
"value": "E SEARCH SALVAGE"
"value": "{0} SEARCH SALVAGE"
},
{
"key": "prompt.relayOnline",
@@ -478,11 +478,11 @@
},
{
"key": "prompt.objective",
"value": "E RECOVER NAVIGATION DATA"
"value": "{0} RECOVER NAVIGATION DATA"
},
{
"key": "prompt.route",
"value": "E CHOOSE ONWARD ROUTE"
"value": "{0} CHOOSE ONWARD ROUTE"
},
{
"key": "prompt.routeLocked",
@@ -490,7 +490,7 @@
},
{
"key": "prompt.board",
"value": "E BOARD / BANK CARGO"
"value": "{0} BOARD / BANK CARGO"
},
{
"key": "prompt.inbound",
@@ -498,7 +498,7 @@
},
{
"key": "prompt.call",
"value": "E CALL EXTRACTION / 15s"
"value": "{0} CALL EXTRACTION / 15s"
},
{
"key": "world.extraction",
@@ -607,6 +607,378 @@
{
"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"
},
{
"key": "settings.title",
"value": "SETTINGS"
},
{
"key": "settings.help",
"value": "Changes are saved when you select Apply. Language changes immediately."
},
{
"key": "settings.language",
"value": "Language"
},
{
"key": "settings.audio",
"value": "Audio"
},
{
"key": "settings.display",
"value": "Display"
},
{
"key": "settings.controls",
"value": "Controls"
},
{
"key": "settings.volume",
"value": "Master volume"
},
{
"key": "settings.volumeHelp",
"value": "Adjust the volume of all game sounds. Set to 0% to mute."
},
{
"key": "settings.percent",
"value": "{0}%"
},
{
"key": "settings.multiplier",
"value": "{0}×"
},
{
"key": "settings.quality",
"value": "Graphics quality"
},
{
"key": "settings.quality.veryLow",
"value": "Very low"
},
{
"key": "settings.quality.low",
"value": "Low"
},
{
"key": "settings.quality.medium",
"value": "Medium"
},
{
"key": "settings.quality.high",
"value": "High"
},
{
"key": "settings.quality.veryHigh",
"value": "Very high"
},
{
"key": "settings.quality.ultra",
"value": "Ultra"
},
{
"key": "settings.resolution",
"value": "Resolution"
},
{
"key": "settings.windowMode",
"value": "Window mode"
},
{
"key": "settings.windowed",
"value": "Windowed"
},
{
"key": "settings.fullscreen",
"value": "Fullscreen"
},
{
"key": "settings.displayHelp",
"value": "After applying a display change, confirm it within 15 seconds or the previous display settings return."
},
{
"key": "settings.display.confirm",
"value": "Keep these display settings?"
},
{
"key": "settings.display.countdown",
"value": "The previous display settings return in {0} seconds."
},
{
"key": "settings.display.keep",
"value": "Keep and save"
},
{
"key": "settings.display.revert",
"value": "Revert"
},
{
"key": "settings.display.reverted",
"value": "The previous display settings have been restored. Other edits are still pending."
},
{
"key": "settings.mouse",
"value": "Mouse"
},
{
"key": "settings.sensitivity",
"value": "Mouse sensitivity"
},
{
"key": "settings.aimSensitivity",
"value": "Aim sensitivity multiplier"
},
{
"key": "settings.bindings",
"value": "Key bindings"
},
{
"key": "settings.bind.help",
"value": "Select a binding, then press a keyboard key or mouse button. Esc cancels capture and always works as a menu back key. Duplicate bindings are rejected."
},
{
"key": "settings.bind.waiting",
"value": "Waiting for input"
},
{
"key": "settings.bind.prompt",
"value": "Press a new key or mouse button for {0}. Press Esc to cancel."
},
{
"key": "settings.bind.cancel",
"value": "Cancel binding"
},
{
"key": "settings.bind.changed",
"value": "The binding for {0} has changed. Select Apply to save it."
},
{
"key": "settings.bind.cancelled",
"value": "Binding cancelled; the previous key is unchanged."
},
{
"key": "settings.bind.conflict",
"value": "This key is already assigned to {0}. Choose another key."
},
{
"key": "settings.bind.reserved",
"value": "Esc is reserved for closing menus and cancelling input. Choose another key."
},
{
"key": "settings.bind.unsupported",
"value": "Choose a keyboard key or mouse button."
},
{
"key": "settings.reset",
"value": "Restore defaults"
},
{
"key": "settings.resetHint",
"value": "Default settings and bindings are ready. Select Apply to save them."
},
{
"key": "settings.back",
"value": "Back / discard edits"
},
{
"key": "settings.apply",
"value": "Apply and save"
},
{
"key": "settings.saved",
"value": "Settings saved."
},
{
"key": "settings.saveFailed",
"value": "Settings could not be saved. Check available disk space and folder access, then try again."
},
{
"key": "settings.action.move.up",
"value": "Move forward"
},
{
"key": "settings.action.move.down",
"value": "Move backward"
},
{
"key": "settings.action.move.left",
"value": "Move left"
},
{
"key": "settings.action.move.right",
"value": "Move right"
},
{
"key": "settings.action.fire",
"value": "Primary weapon"
},
{
"key": "settings.action.aim",
"value": "Precision aim"
},
{
"key": "settings.action.boost",
"value": "Boost"
},
{
"key": "settings.action.jump",
"value": "Jump / lift"
},
{
"key": "settings.action.view",
"value": "Switch perspective"
},
{
"key": "settings.action.interact",
"value": "Interact"
},
{
"key": "settings.action.inventory",
"value": "Open cargo hold"
},
{
"key": "settings.action.cargo",
"value": "Launch cargo pod"
},
{
"key": "settings.action.pause",
"value": "Pause"
},
{
"key": "settings.action.secondary",
"value": "Secondary weapon"
},
{
"key": "settings.action.guard",
"value": "Active defense"
},
{
"key": "settings.key.mouseLeft",
"value": "Mouse left"
},
{
"key": "settings.key.mouseRight",
"value": "Mouse right"
},
{
"key": "settings.key.mouseMiddle",
"value": "Mouse middle"
},
{
"key": "settings.key.mouseForward",
"value": "Mouse forward"
},
{
"key": "settings.key.mouseBack",
"value": "Mouse back"
},
{
"key": "settings.key.space",
"value": "Space"
},
{
"key": "settings.key.leftShift",
"value": "Left Shift"
},
{
"key": "settings.key.rightShift",
"value": "Right Shift"
},
{
"key": "settings.action.reload",
"value": "Reload"
},
{
"key": "ui.combat.nav",
"value": "SECTOR MAP"
},
{
"key": "ui.combat.legend",
"value": "◇ Objective ○ Extraction"
},
{
"key": "ui.combat.ammo",
"value": "MAGAZINE / UNLIMITED RESERVE"
},
{
"key": "ui.combat.reload",
"value": "[{0}] Reload"
},
{
"key": "ui.combat.loading",
"value": "Reloading {0:0.0}s"
},
{
"key": "ui.combat.ready",
"value": "Ready"
},
{
"key": "ui.combat.charging",
"value": "Cooldown {0:0.0}s"
},
{
"key": "ui.combat.lowEnergy",
"value": "Insufficient energy"
},
{
"key": "ui.combat.cargo",
"value": "Hold {0}/16 cells · Safe case {1}/4 cells"
},
{
"key": "ui.combat.empty",
"value": "Empty slot"
},
{
"key": "range.resetSign",
"value": "RESET DESK"
}
]
}
+397 -25
View File
@@ -46,19 +46,19 @@
},
{
"key": "range.recoveryGuide",
"value": "按 E 搜索箱子,Tab 选择货物,停稳后长按 G 寄送。"
"value": "按 [{0}] 搜索箱子,按 [{1}] 选择货物,原地按住 [{2}] 发射。"
},
{
"key": "range.recoverySent",
"value": "货物已寄送。在橙色平台按 E 呼叫运输机。"
"value": "货舱已发射。前往橙色平台按 [{0}] 呼叫运输机。"
},
{
"key": "range.board",
"value": "运输机已到,在橙色平台按 E 登机。"
"value": "运输机已抵达。前往橙色平台按 [{0}] 登机。"
},
{
"key": "range.reset",
"value": "复位训练台 [E]"
"value": "重置练习 [{0}]"
},
{
"key": "range.gate",
@@ -118,15 +118,15 @@
},
{
"key": "ui.home.locked",
"value": "所选高级部件尚未研究蓝图。可在驾驶试验场试用任意配装。"
"value": "所选装备库存不足。基础槽位可借用救济装,试验场可试用所有型号。"
},
{
"key": "ui.home.moveHint",
"value": "WASD 移动 / 鼠标瞄准 / V 切换视角 / Shift 推进 / Space 跳跃与升空"
"value": "{0} 移动 / 鼠标瞄准 / {1} 切换视角 / {2} 推进 / {3} 跳跃与升空"
},
{
"key": "ui.home.fireHint",
"value": "鼠标左键 主武器 / 右键 精瞄 / Q 副武装 / F 护盾 / E 交互"
"value": "{0} 主武器 / {1} 精瞄 / {2} 副武装 / {3} 护盾 / {4} 交互"
},
{
"key": "ui.home.inventory",
@@ -186,11 +186,11 @@
},
{
"key": "ui.hud.podReady",
"value": "回收舱就绪 / Tab 选择货物"
"value": "回收舱就绪 / {0} 选择货物"
},
{
"key": "ui.hud.hint",
"value": "V 视角 Shift 推进 Space 升空 Q 副武装 F 护盾 E 交互 Tab 货仓 按住 G 发射回收舱 Esc 暂停"
"value": "{0} 视角 {1} 推进 {2} 升空 {3} 副武装 {4} 护盾 {5} 交互 {6} 货仓 按住 {7} 发射回收舱 {8} 暂停"
},
{
"key": "ui.hud.zone",
@@ -230,7 +230,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",
@@ -246,15 +246,15 @@
},
{
"key": "ui.cargo.title",
"value": "随身货仓"
"value": "货仓与弹射式安全箱"
},
{
"key": "ui.cargo.used",
"value": "回收舱已使用 / 剩余货物需要随整机撤离。"
"value": "主动货舱已发射。机毁时只有弹射式安全箱中的物品返航。"
},
{
"key": "ui.cargo.help",
"value": "最多选择两组货物。关闭面板后原地按住 G 3 秒,发射回收舱。"
"value": "拖动物品或选中后点击目标格,按 R 旋转。安全箱在机毁时自动返航。标记货物后关闭面板,原地按住 {0} 三秒发射主动货舱。"
},
{
"key": "ui.cargo.stack",
@@ -266,7 +266,7 @@
},
{
"key": "ui.cargo.capacity",
"value": "随身货仓 {0} / 4 回收舱 {1} / 2"
"value": "普通货仓 {0}/16 格 主动货舱 {1}/4 格"
},
{
"key": "ui.return",
@@ -362,7 +362,7 @@
},
{
"key": "report.destroyed",
"value": "机甲损失 / 丢失 {0} 组随身货物。已送回的货物仍保留在机库。"
"value": "机体损毁。损失 {0} 件装备或货物,弹射式安全箱送回 {1} 件物品。"
},
{
"key": "report.complete",
@@ -370,7 +370,7 @@
},
{
"key": "report.extracted",
"value": "提前撤离 / 随身材料与蓝图已全部入库。"
"value": "撤离成功。已装装备、普通货仓和安全箱物品已回收。"
},
{
"key": "notice.unfinished",
@@ -382,7 +382,7 @@
},
{
"key": "notice.researchFirst",
"value": "请先研究所选装备蓝图,或在驾驶试验场试用。"
"value": "机库中没有所选装备。"
},
{
"key": "notice.saveFailed",
@@ -390,7 +390,7 @@
},
{
"key": "notice.range",
"value": "驾驶试验场 / V 切换视角,E 操作附近装置。"
"value": "驾驶试验场 / {0} 切换视角,{1} 操作附近设备。"
},
{
"key": "notice.enter",
@@ -406,7 +406,7 @@
},
{
"key": "notice.twoSlots",
"value": "回收舱最多容纳两组货物。"
"value": "所选物品放不进 2×2 主动货舱。"
},
{
"key": "notice.holdFull",
@@ -462,7 +462,7 @@
},
{
"key": "notice.transportReady",
"value": "运输机已抵达 / 返回橙色平台并按 E 登机。"
"value": "接应运输机已抵达 / 返回橙色撤离坪,按 {0} 登机。"
},
{
"key": "notice.escortLost",
@@ -470,7 +470,7 @@
},
{
"key": "prompt.search",
"value": "E 搜索物资"
"value": "{0} 搜索物资"
},
{
"key": "prompt.relayOnline",
@@ -478,11 +478,11 @@
},
{
"key": "prompt.objective",
"value": "E 回收导航数据"
"value": "{0} 取回导航数据"
},
{
"key": "prompt.route",
"value": "E 选择下一站路线"
"value": "{0} 选择下一站路线"
},
{
"key": "prompt.routeLocked",
@@ -490,7 +490,7 @@
},
{
"key": "prompt.board",
"value": "E 登机 / 货物入库"
"value": "{0} 登机 / 带回随身物资"
},
{
"key": "prompt.inbound",
@@ -498,7 +498,7 @@
},
{
"key": "prompt.call",
"value": "E 呼叫撤离 / 等待 15 秒"
"value": "{0} 呼叫接应 / 15 秒"
},
{
"key": "world.extraction",
@@ -607,6 +607,378 @@
{
"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}"
},
{
"key": "settings.title",
"value": "设置"
},
{
"key": "settings.help",
"value": "点击应用后保存设置,语言切换即时生效。"
},
{
"key": "settings.language",
"value": "语言"
},
{
"key": "settings.audio",
"value": "音频"
},
{
"key": "settings.display",
"value": "画面"
},
{
"key": "settings.controls",
"value": "操作与按键"
},
{
"key": "settings.volume",
"value": "总音量"
},
{
"key": "settings.volumeHelp",
"value": "调节游戏内所有声音的音量,设为 0% 即可静音。"
},
{
"key": "settings.percent",
"value": "{0}%"
},
{
"key": "settings.multiplier",
"value": "{0} 倍"
},
{
"key": "settings.quality",
"value": "画质"
},
{
"key": "settings.quality.veryLow",
"value": "极低"
},
{
"key": "settings.quality.low",
"value": "低"
},
{
"key": "settings.quality.medium",
"value": "中"
},
{
"key": "settings.quality.high",
"value": "高"
},
{
"key": "settings.quality.veryHigh",
"value": "极高"
},
{
"key": "settings.quality.ultra",
"value": "最高"
},
{
"key": "settings.resolution",
"value": "分辨率"
},
{
"key": "settings.windowMode",
"value": "显示模式"
},
{
"key": "settings.windowed",
"value": "窗口"
},
{
"key": "settings.fullscreen",
"value": "全屏"
},
{
"key": "settings.displayHelp",
"value": "更改分辨率或显示模式后,需要在 15 秒内确认,否则自动恢复上一次显示设置。"
},
{
"key": "settings.display.confirm",
"value": "保留当前显示设置?"
},
{
"key": "settings.display.countdown",
"value": "将在 {0} 秒后自动恢复上一次显示设置。"
},
{
"key": "settings.display.keep",
"value": "保留并保存"
},
{
"key": "settings.display.revert",
"value": "恢复原设置"
},
{
"key": "settings.display.reverted",
"value": "已恢复上一次显示设置,其他修改仍待应用。"
},
{
"key": "settings.mouse",
"value": "鼠标"
},
{
"key": "settings.sensitivity",
"value": "鼠标灵敏度"
},
{
"key": "settings.aimSensitivity",
"value": "精瞄灵敏度倍率"
},
{
"key": "settings.bindings",
"value": "按键设置"
},
{
"key": "settings.bind.help",
"value": "点击按键后,按下键盘按键或鼠标按钮进行改绑。Esc 取消改绑,并始终可用于返回菜单;重复按键会提示冲突。"
},
{
"key": "settings.bind.waiting",
"value": "等待按键"
},
{
"key": "settings.bind.prompt",
"value": "请为“{0}”按下新的键盘按键或鼠标按钮,按 Esc 取消。"
},
{
"key": "settings.bind.cancel",
"value": "取消改绑"
},
{
"key": "settings.bind.changed",
"value": "已更改“{0}”的按键,点击应用后保存。"
},
{
"key": "settings.bind.cancelled",
"value": "已取消改绑,保留原按键。"
},
{
"key": "settings.bind.conflict",
"value": "该按键已用于“{0}”,请选择其他按键。"
},
{
"key": "settings.bind.reserved",
"value": "Esc 用于关闭菜单和取消输入,请选择其他按键。"
},
{
"key": "settings.bind.unsupported",
"value": "请选择键盘按键或鼠标按钮。"
},
{
"key": "settings.reset",
"value": "恢复默认"
},
{
"key": "settings.resetHint",
"value": "已恢复默认设置与按键,点击应用后保存。"
},
{
"key": "settings.back",
"value": "返回 / 放弃修改"
},
{
"key": "settings.apply",
"value": "应用并保存"
},
{
"key": "settings.saved",
"value": "设置已保存。"
},
{
"key": "settings.saveFailed",
"value": "设置保存失败,请检查磁盘空间与目录写入权限后重试。"
},
{
"key": "settings.action.move.up",
"value": "向前移动"
},
{
"key": "settings.action.move.down",
"value": "向后移动"
},
{
"key": "settings.action.move.left",
"value": "向左移动"
},
{
"key": "settings.action.move.right",
"value": "向右移动"
},
{
"key": "settings.action.fire",
"value": "主武器"
},
{
"key": "settings.action.aim",
"value": "精瞄"
},
{
"key": "settings.action.boost",
"value": "推进"
},
{
"key": "settings.action.jump",
"value": "跳跃 / 升空"
},
{
"key": "settings.action.view",
"value": "切换视角"
},
{
"key": "settings.action.interact",
"value": "交互"
},
{
"key": "settings.action.inventory",
"value": "打开货仓"
},
{
"key": "settings.action.cargo",
"value": "发射回收舱"
},
{
"key": "settings.action.pause",
"value": "暂停"
},
{
"key": "settings.action.secondary",
"value": "副武装"
},
{
"key": "settings.action.guard",
"value": "主动防护"
},
{
"key": "settings.key.mouseLeft",
"value": "鼠标左键"
},
{
"key": "settings.key.mouseRight",
"value": "鼠标右键"
},
{
"key": "settings.key.mouseMiddle",
"value": "鼠标中键"
},
{
"key": "settings.key.mouseForward",
"value": "鼠标前侧键"
},
{
"key": "settings.key.mouseBack",
"value": "鼠标后侧键"
},
{
"key": "settings.key.space",
"value": "空格"
},
{
"key": "settings.key.leftShift",
"value": "左 Shift"
},
{
"key": "settings.key.rightShift",
"value": "右 Shift"
},
{
"key": "settings.action.reload",
"value": "换弹"
},
{
"key": "ui.combat.nav",
"value": "区域地图"
},
{
"key": "ui.combat.legend",
"value": "◇ 目标 ○ 撤离点"
},
{
"key": "ui.combat.ammo",
"value": "弹匣 / 无限备弹"
},
{
"key": "ui.combat.reload",
"value": "[{0}] 换弹"
},
{
"key": "ui.combat.loading",
"value": "换弹中 {0:0.0} 秒"
},
{
"key": "ui.combat.ready",
"value": "就绪"
},
{
"key": "ui.combat.charging",
"value": "冷却 {0:0.0} 秒"
},
{
"key": "ui.combat.lowEnergy",
"value": "能源不足"
},
{
"key": "ui.combat.cargo",
"value": "普通货仓 {0}/16 格 · 安全箱 {1}/4 格"
},
{
"key": "ui.combat.empty",
"value": "未安装"
},
{
"key": "range.resetSign",
"value": "训练复位台"
}
]
}
@@ -12,6 +12,8 @@ namespace Ironspore
public float mass = 2;
public float power = 2;
public float damage = 15;
[Min(1)] public int magazineSize = 36;
[Min(.1f)] public float reloadSeconds = 2;
public float fireRate = 6;
public float speedFactor = 1;
public bool guided;
@@ -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
+5 -3
View File
@@ -59,8 +59,9 @@ namespace Ironspore
folder = Path.GetFullPath(Arg("-lighting-output", Path.Combine(Application.persistentDataPath, "LightingQA")));
Directory.CreateDirectory(folder);
Directory.CreateDirectory(Path.Combine(folder, "Screenshots"));
if (!Application.productName.Contains("Lighting tree3"))
throw new InvalidOperationException("Lighting QA requires the isolated tree3 product namespace.");
string isolated = Arg("-ironspore-data-path", "");
if (!Path.IsPathRooted(isolated) || !string.Equals(GameDataPaths.DirectoryPath, Path.GetFullPath(isolated), StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Lighting QA requires an absolute -ironspore-data-path.");
Application.runInBackground = true;
Screen.SetResolution(1920, 1080, FullScreenMode.FullScreenWindow);
InputSystem.settings.backgroundBehavior = InputSettings.BackgroundBehavior.IgnoreFocus;
@@ -93,6 +94,7 @@ namespace Ironspore
report.vSync = QualitySettings.vSyncCount;
report.frameCap = Application.targetFrameRate;
report.persistentDataPath = Application.persistentDataPath;
report.dataDirectory = GameDataPaths.DirectoryPath;
report.frameTimingEnabled = FrameTimingManager.IsFeatureEnabled();
keyboard = InputSystem.AddDevice<Keyboard>("LightingQaKeyboard");
mouse = InputSystem.AddDevice<Mouse>("LightingQaMouse");
@@ -395,7 +397,7 @@ namespace Ironspore
}
[Serializable] sealed class Report
{
public string tag, startedUtc, finishedUtc, gpu, api, cpu, colorSpace, quality, persistentDataPath, routeVersion;
public string tag, startedUtc, finishedUtc, gpu, api, cpu, colorSpace, quality, persistentDataPath, dataDirectory, routeVersion;
public int width, height, gpuMemoryMiB, systemMemoryMiB, cascades, vSync, frameCap, firstPersonShadowOnlyRenderers, movingShadowPixels, sunShadowPixels, localLitPixels, localBlockedPixels, sunResolution, cameraWidth, cameraHeight, branch;
public float shadowDistance, renderWidthScale, renderHeightScale;
public double warmupSeconds, sampleSeconds;
@@ -7,7 +7,7 @@ namespace Ironspore
{
public static class GameLocalization
{
public const string PreferenceKey = "settings.locale";
public static string PreferenceKey => GameDataPaths.PreferenceKey("settings.locale");
public const string FallbackCode = "en";
public static event Action Changed;
public static string CurrentCode { get; private set; } = FallbackCode;
@@ -40,6 +40,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 +49,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)
+36 -9
View File
@@ -27,6 +27,11 @@ namespace Ironspore
{
get; private set;
}
public int Rounds { get; private set; }
public float ReloadRemaining { get; private set; }
public float SecondaryCooldown => Mathf.Max(0, sideCooldown);
public float Heading => yaw;
public EquipmentDefinition Primary { get; private set; }
GameSession game;
CharacterController motor;
Camera view;
@@ -76,6 +81,13 @@ namespace Ironspore
}
public void ApplyLoadout()
{
var primary = game.Part(0, game.Run.equipment[0]);
if (Primary != primary)
{
Primary = primary;
Rounds = primary ? primary.magazineSize : 0;
ReloadRemaining = 0;
}
visual.localScale = game.chassis[game.Run.chassis].modelScale;
}
public void RequireFreshTrigger()
@@ -84,10 +96,16 @@ namespace Ironspore
}
void Update()
{
if (!game.InField || game.Paused)
if (!game.InField || game.Paused || Health <= 0)
return;
var frame = game.chassis[game.Run.chassis];
float dt = Time.deltaTime;
if (ReloadRemaining > 0)
{
ReloadRemaining = Mathf.Max(0, ReloadRemaining - dt);
if (ReloadRemaining == 0)
Rounds = Primary.magazineSize;
}
boostCooldown -= dt;
shotCooldown -= dt;
sideCooldown -= dt;
@@ -97,16 +115,18 @@ namespace Ironspore
if (!game.MenuOpen)
{
Vector2 look = game.Action("Look").ReadValue<Vector2>();
yaw += look.x * .105f;
pitch = Mathf.Clamp(pitch - look.y * .105f, -72, 75);
float sensitivity = .105f * game.Settings.LookSensitivity
* (game.Action("Aim").IsPressed() ? game.Settings.AimSensitivity : 1);
yaw += look.x * sensitivity;
pitch = Mathf.Clamp(pitch - look.y * sensitivity, -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();
@@ -125,7 +145,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)
{
@@ -148,9 +168,15 @@ namespace Ironspore
{
if (!game.Action("Fire").IsPressed())
freshTrigger = false;
if (!freshTrigger && game.Action("Fire").IsPressed() && shotCooldown <= 0)
if (Primary && game.Action("Reload").WasPressedThisFrame() && Rounds < Primary.magazineSize && ReloadRemaining == 0)
ReloadRemaining = Primary.reloadSeconds;
if (Primary && !freshTrigger && game.Action("Fire").IsPressed() && shotCooldown <= 0 && ReloadRemaining == 0 && Rounds > 0)
{
Shoot(false);
if (game.Action("Secondary").WasPressedThisFrame() && sideCooldown <= 0 && Energy >= 15)
if (--Rounds == 0)
ReloadRemaining = Primary.reloadSeconds;
}
if (game.Action("Secondary").WasPressedThisFrame() && game.Run.equipment[1] >= 0 && sideCooldown <= 0 && Energy >= 15)
{
Energy -= 15;
Shoot(true);
@@ -186,6 +212,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);
@@ -228,7 +255,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;
}
+16 -5
View File
@@ -14,6 +14,8 @@ namespace Ironspore
Camera observer;
bool ownsProfile;
int shadowBudget;
int qualityLevel = -1;
Light sun;
bool captureQueued, capturing;
float captureAfter;
public bool ReflectionReady { get; private set; }
@@ -26,8 +28,6 @@ namespace Ironspore
profile = ScriptableObject.CreateInstance<WorldLightingProfile>();
ownsProfile = true;
}
int level = QualitySettings.GetQualityLevel();
shadowBudget = level <= 1 ? 0 : level >= 4 ? 2 : 1;
RenderSettings.ambientMode = AmbientMode.Trilight;
RenderSettings.ambientSkyColor = profile.ambientSky;
RenderSettings.ambientEquatorColor = profile.ambientEquator;
@@ -46,18 +46,17 @@ namespace Ironspore
var sunObject = new GameObject("Sun");
sunObject.transform.SetParent(transform, false);
sunObject.transform.rotation = Quaternion.Euler(profile.sunEuler);
var sun = sunObject.AddComponent<Light>();
sun = sunObject.AddComponent<Light>();
sun.type = LightType.Directional;
sun.color = profile.sunColor;
sun.intensity = hangar ? profile.hangarSunIntensity : profile.sunIntensity;
sun.renderMode = LightRenderMode.ForcePixel;
sun.shadows = level <= 1 ? LightShadows.Hard : LightShadows.Soft;
sun.shadowStrength = 1;
sun.shadowBias = profile.bias;
sun.shadowNormalBias = profile.normalBias;
sun.shadowNearPlane = profile.shadowNearPlane;
sun.shadowCustomResolution = level <= 1 ? 1024 : level >= 4 ? 4096 : 2048;
RenderSettings.sun = sun;
ApplyQuality();
}
public void CompleteEnvironment(Camera camera, bool hangar)
@@ -121,8 +120,18 @@ namespace Ironspore
fixtures.Add(light);
}
void ApplyQuality()
{
qualityLevel = QualitySettings.GetQualityLevel();
shadowBudget = qualityLevel <= 1 ? 0 : qualityLevel >= 4 ? 2 : 1;
sun.shadows = qualityLevel <= 1 ? LightShadows.Hard : LightShadows.Soft;
sun.shadowCustomResolution = qualityLevel <= 1 ? 1024 : qualityLevel >= 4 ? 4096 : 2048;
captureQueued = true;
}
void LateUpdate()
{
if (qualityLevel != QualitySettings.GetQualityLevel()) ApplyQuality();
SelectLights();
if (environment && captureQueued && !capturing && Time.unscaledTime >= captureAfter)
StartCoroutine(CaptureEnvironment());
@@ -156,6 +165,8 @@ namespace Ironspore
capturing = true;
captureQueued = false;
yield return null;
// Change the probe size only after the previous capture has completed.
environment.resolution = QualitySettings.GetQualityLevel() >= 4 ? 256 : 128;
DynamicGI.UpdateEnvironment();
int render = environment.RenderProbe();
while (environment && !environment.IsFinishedRendering(render))
+76 -35
View File
@@ -43,6 +43,7 @@ namespace Ironspore
{
get; private set;
}
public SettingsService Settings { get; private set; }
public readonly List<string> SelectedCargo = new List<string>();
public bool MenuOpen { get; private set; } = true;
public bool Paused
@@ -54,6 +55,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() ?? "";
@@ -77,9 +79,11 @@ namespace Ironspore
Active = this;
GameLocalization.Initialize(localization);
Application.targetFrameRate = 60;
mainStore = new ProfileStore(Path.Combine(Application.persistentDataPath, "Ironspore", "profile.json"));
mainStore = new ProfileStore(ProfileStore.SavePath(false));
Store = mainStore;
Input = Instantiate(inputTemplate);
var defaults = SettingsService.RuntimeDefaults();
Settings = new SettingsService(new SettingsStore(Path.Combine(GameDataPaths.DirectoryPath, "settings.json"), defaults), Input, defaults);
Input.Enable();
World = gameObject.AddComponent<WarzoneDirector>();
Hud = gameObject.AddComponent<GameHud>();
@@ -91,14 +95,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)
@@ -107,6 +113,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;
}
@@ -114,10 +122,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()
@@ -139,7 +150,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;
@@ -148,17 +159,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()
{
@@ -170,7 +187,10 @@ namespace Ironspore
Pilot = World.Player;
SetMenu(false);
Hud.ShowField();
Notify(Trial ? "notice.range" : "notice.enter");
if (Trial)
Notify("notice.range", Settings.Bindings.Display("View"), Settings.Bindings.Display("Interact"));
else
Notify("notice.enter");
}
public void SetMenu(bool open)
{
@@ -212,6 +232,13 @@ namespace Ironspore
noticeMessage = null;
if (!InField || !Pilot || ending)
return;
if (Pilot.Health <= 0 || Run.destructionPending)
{
EndSortie(true);
return;
}
if (Hud && (Hud.SettingsOpen || Hud.MenuInputConsumedThisFrame))
return;
if (Action("Pause").WasPressedThisFrame())
{
if (MenuOpen)
@@ -240,11 +267,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)
{
@@ -282,10 +304,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()
@@ -297,7 +321,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;
@@ -309,22 +335,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()
{
@@ -399,15 +430,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;
@@ -417,6 +452,11 @@ namespace Ironspore
}
void OnApplicationFocus(bool focus)
{
if (!focus && Hud && Hud.SettingsOpen)
{
Hud.CancelSettingsInput();
return;
}
if (!focus && InField && Hud)
{
Paused = true;
@@ -435,6 +475,7 @@ namespace Ironspore
}
void OnDestroy()
{
Settings?.Dispose();
if (Input)
Destroy(Input);
Time.timeScale = 1;
+4 -4
View File
@@ -22,9 +22,9 @@ namespace Ironspore
get
{
Vector3 position = game.World.Player.transform.position;
if (game.World.TransportReady) return GameLocalization.Text("range.board");
if (game.World.TransportReady) return GameLocalization.Text("range.board", game.Settings.Bindings.Display("Interact"));
if (position.x < -16 && (position.z > 6 || Mathf.Abs(position.z + 8) < 4))
return GameLocalization.Text(game.Run.podUsed ? "range.recoverySent" : "range.recoveryGuide");
return GameLocalization.Text(game.Run.podUsed ? "range.recoverySent" : "range.recoveryGuide", game.Settings.Bindings.Display("Interact"), game.Settings.Bindings.Display("Inventory"), game.Settings.Bindings.Display("Cargo"));
if (position.x < -7) return GameLocalization.Text("range.targetGuide", hits);
if (gateCount == 3) return GameLocalization.Text("range.finish", finishSeconds);
if (gateCount == 2) return GameLocalization.Text("range.brake");
@@ -57,7 +57,7 @@ namespace Ironspore
var reset = desk.AddComponent<RangeReset>();
reset.kind = 4;
reset.course = this;
WorldFactory.Label("range.reset", desk.transform.position + Vector3.up * 1.6f, Green, transform);
WorldFactory.Label("range.resetSign", desk.transform.position + Vector3.up * 1.6f, Green, transform);
}
Renderer Gate(Vector3 at)
@@ -209,7 +209,7 @@ namespace Ironspore
public sealed class RangeReset : WorldInteractable
{
public TrainingCourse course;
public override string Prompt => GameLocalization.Text("range.reset");
public override string Prompt => GameLocalization.Text("range.reset", GameSession.Active.Settings.Bindings.Display("Interact"));
public override void Activate() => course.ResetPractice();
}
}
+21 -5
View File
@@ -21,6 +21,13 @@ namespace Ironspore
{
get; private set;
}
Transform relay, onwardGate, terminalTarget;
public Transform NavigationTarget => game.Run.routeCompleted ? null :
!game.Trial && game.Run.stage == 4 ? terminalTarget :
game.Run.objectiveDone ? onwardGate : relay;
public Vector3 ExtractionPosition => extractionPosition;
readonly System.Collections.Generic.List<Rect> cover = new System.Collections.Generic.List<Rect>();
public System.Collections.Generic.IReadOnlyList<Rect> Cover => cover;
Transform root;
GameSession game;
WorldLighting lighting;
@@ -42,6 +49,8 @@ namespace Ironspore
Focused = null;
Course = null;
Escort = null;
relay = onwardGate = terminalTarget = null;
cover.Clear();
lighting = root.gameObject.AddComponent<WorldLighting>();
lighting.Initialize(game.lightingProfile, hangar);
WorldFactory.Box("Ground", new Vector3(0, -.3f, 8), new Vector3(72, .6f, 100), new Color(.11f, .16f, .18f), root);
@@ -95,6 +104,7 @@ namespace Ironspore
{
float x = (i % 2 == 0 ? -1 : 1) * (8 + (i % 3) * 5);
float z = -10 + i * 6;
cover.Add(new Rect(x - 2.75f, z - 1.5f, 5.5f, 3));
var color = i % 2 == 0 ? new Color(.22f, .34f, .37f) : new Color(.45f, .26f, .15f);
WorldFactory.Box("FreightContainer", new Vector3(x, 1.1f, z), new Vector3(5.5f, 2.2f, 3), color, root);
for (int rib = 0; rib < 5; rib++)
@@ -126,10 +136,12 @@ namespace Ironspore
}
var objective = WorldFactory.Box("NavigationRelay", new Vector3(0, 1.5f, 23), new Vector3(2.2f, 3, 1.6f), new Color(.21f, .33f, .36f), root);
WorldFactory.Box("RelayScreen", new Vector3(0, 2.4f, 22.14f), new Vector3(1.5f, .65f, .1f), new Color(.1f, .9f, .83f), root, false, true);
relay = objective.transform;
var objectiveAction = objective.AddComponent<WorldInteractable>();
objectiveAction.kind = 1;
objectiveAction.RefreshLabel();
var onward = WorldFactory.Cylinder("OnwardGate", new Vector3(0, .05f, 36), new Vector3(7, .1f, 7), new Color(.3f, .6f, .65f), root, false);
onwardGate = onward.transform;
var onwardAction = onward.AddComponent<WorldInteractable>();
onwardAction.kind = 2;
onwardAction.RefreshLabel();
@@ -155,6 +167,7 @@ namespace Ironspore
if (run.branch == 1 && !run.routeCompleted)
{
var cart = WorldFactory.Box("EscortEngineer", new Vector3(0, 1.3f, 0), new Vector3(3, 2.6f, 4), new Color(.2f, .55f, .5f), root);
terminalTarget = cart.transform;
Escort = cart.AddComponent<EscortUnit>();
Escort.Initialize(game);
cart.layer = 10;
@@ -173,6 +186,8 @@ namespace Ironspore
obj.transform.SetParent(root);
obj.transform.position = pos;
obj.layer = 9;
if (type == 3)
terminalTarget = obj.transform;
obj.AddComponent<EnemyUnit>().Initialize(game, type, game.mechModel, root);
}
public void CallExtraction()
@@ -204,7 +219,7 @@ namespace Ironspore
transport = WorldFactory.Box("RecoveryTransport", extractionPosition + new Vector3(0, 5.5f, 0), new Vector3(7, 1.8f, 10), new Color(.24f, .3f, .32f), root, false);
transport.layer = 10;
WorldFactory.Box("TransportWing", transport.transform.position, new Vector3(14, .5f, 3), new Color(.32f, .4f, .41f), root, false).layer = 10;
game.Notify("notice.transportReady");
game.Notify("notice.transportReady", game.Settings.Bindings.Display("Interact"));
GameAudio.Cue(950, .3f);
}
}
@@ -242,13 +257,14 @@ namespace Ironspore
get
{
var g = GameSession.Active;
string interact = g.Settings.Bindings.Display("Interact");
if (kind == 0)
return GameLocalization.Text("prompt.search");
return GameLocalization.Text("prompt.search", interact);
if (kind == 1)
return GameLocalization.Text(g.Run.objectiveDone ? "prompt.relayOnline" : "prompt.objective");
return GameLocalization.Text(g.Run.objectiveDone ? "prompt.relayOnline" : "prompt.objective", interact);
if (kind == 2)
return GameLocalization.Text(g.Run.objectiveDone ? "prompt.route" : "prompt.routeLocked");
return GameLocalization.Text(g.World.TransportReady ? "prompt.board" : g.World.ExtractionRemaining >= 0 ? "prompt.inbound" : "prompt.call");
return GameLocalization.Text(g.Run.objectiveDone ? "prompt.route" : "prompt.routeLocked", interact);
return GameLocalization.Text(g.World.TransportReady ? "prompt.board" : g.World.ExtractionRemaining >= 0 ? "prompt.inbound" : "prompt.call", interact);
}
}
public virtual void RefreshLabel()
+42
View File
@@ -0,0 +1,42 @@
using System;
using System.IO;
using UnityEngine;
namespace Ironspore
{
public static class GameDataPaths
{
public static string DirectoryPath
{
get
{
string isolated = CommandLineDirectory();
if (isolated != null)
return isolated;
#if UNITY_EDITOR
return Path.GetFullPath(Path.Combine(Application.dataPath, "../UserSettings/Ironspore"));
#else
return Path.Combine(Application.persistentDataPath, "Ironspore");
#endif
}
}
public static string PreferenceKey(string key)
{
string isolated = CommandLineDirectory();
#if UNITY_EDITOR
isolated = isolated ?? DirectoryPath;
#endif
return isolated == null ? key : "ironspore." + Hash128.Compute(isolated.ToLowerInvariant()) + "." + key;
}
static string CommandLineDirectory()
{
string[] args = Environment.GetCommandLineArgs();
for (int i = 0; i + 1 < args.Length; i++)
if (args[i] == "-ironspore-data-path" && Path.IsPathRooted(args[i + 1]))
return Path.GetFullPath(args[i + 1]);
return null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 519f858dabbde1042a2c7fba13058b93
@@ -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
+53 -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,23 @@ namespace Ironspore
get; private set;
}
public static string SavePath(bool trial)
{
return Path.Combine(GameDataPaths.DirectoryPath, 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 +102,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 +133,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 +143,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 =>
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 313dde83e009ad347a3a02ebacbde7d4
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,43 @@
using System;
using UnityEngine;
namespace Ironspore
{
[Serializable]
public sealed class GameSettingsData
{
public int version = 1;
public float masterVolume = 1;
public int qualityLevel = 2;
public int resolutionWidth = 1280;
public int resolutionHeight = 720;
public bool fullscreen;
public float mouseSensitivity = 1;
public float aimSensitivity = 1;
public string bindingOverrides = "";
public GameSettingsData Copy() => (GameSettingsData)MemberwiseClone();
public GameSettingsData Normalize(int qualityCount)
{
var copy = Copy();
copy.masterVolume = FiniteClamp(masterVolume, 0, 1, 1);
copy.mouseSensitivity = FiniteClamp(mouseSensitivity, .1f, 5, 1);
copy.aimSensitivity = FiniteClamp(aimSensitivity, .1f, 2, 1);
copy.qualityLevel = Mathf.Clamp(qualityLevel, 0, Mathf.Max(0, qualityCount - 1));
if (resolutionWidth < 640 || resolutionWidth > 16384 || resolutionHeight < 360 || resolutionHeight > 16384)
{
copy.resolutionWidth = 1280;
copy.resolutionHeight = 720;
}
copy.bindingOverrides = bindingOverrides ?? "";
return copy;
}
public bool SameDisplay(GameSettingsData other) => resolutionWidth == other.resolutionWidth
&& resolutionHeight == other.resolutionHeight && fullscreen == other.fullscreen;
static float FiniteClamp(float value, float min, float max, float fallback) =>
float.IsNaN(value) || float.IsInfinity(value) ? fallback : Mathf.Clamp(value, min, max);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7a1196ee2b7e1734abda51de9ef9c0c4
@@ -0,0 +1,225 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
namespace Ironspore
{
public sealed class BindingSlot
{
public readonly InputAction Action;
public readonly int Index;
public readonly string LabelKey;
public BindingSlot(InputAction action, int index)
{
Action = action;
Index = index;
LabelKey = "settings.action." + action.name.ToLowerInvariant()
+ (action.bindings[index].isPartOfComposite ? "." + action.bindings[index].name.ToLowerInvariant() : "");
}
}
public enum BindingChange { Success, Cancelled, Conflict, Reserved, Unsupported }
public sealed class InputBindingService : IDisposable
{
readonly InputActionAsset input;
readonly List<BindingSlot> slots = new List<BindingSlot>();
readonly List<InputAction> enabledActions = new List<InputAction>();
InputActionRebindingExtensions.RebindingOperation operation;
BindingSlot activeSlot;
Action<BindingChange, BindingSlot> onCompleted;
public IReadOnlyList<BindingSlot> Slots => slots;
public bool IsRebinding => operation != null;
public int LastRebindFrame { get; private set; } = -1;
// Input callbacks can run before Unity advances frameCount for Update.
public bool SuppressMenuInput => IsRebinding || (LastRebindFrame >= 0 && Time.frameCount <= LastRebindFrame + 1);
public InputBindingService(InputActionAsset input)
{
this.input = input;
foreach (var action in input.FindActionMap("Pilot", true).actions)
for (int i = 0; i < action.bindings.Count; i++)
if (action.name != "Look" && !action.bindings[i].isComposite)
slots.Add(new BindingSlot(action, i));
}
public string Save() => input.SaveBindingOverridesAsJson();
public bool TryLoad(string json)
{
Cancel();
string previous = Save();
try
{
input.RemoveAllBindingOverrides();
if (!string.IsNullOrEmpty(json))
input.LoadBindingOverridesFromJson(json);
// Older profiles may already use R. Preserve their binding and move Reload.
var reload = slots.Find(slot => slot.Action.name == "Reload");
if (reload != null && !reload.Action.bindings[reload.Index].hasOverrides &&
Check(reload, reload.Action.bindings[reload.Index].path, out _) == BindingChange.Conflict)
{
foreach (char letter in "tyuiohjklbnmpxczaqwsdefgvr")
if (Rebind(reload, "<Keyboard>/" + letter, out _) == BindingChange.Success)
break;
}
foreach (var map in input.actionMaps)
foreach (var action in map.actions)
foreach (var binding in action.bindings)
if (binding.hasOverrides && (action.name == "Look" || binding.isComposite
|| binding.overrideInteractions != null || binding.overrideProcessors != null))
throw new ArgumentException("Only button binding paths can be overridden.");
foreach (var slot in slots)
if (slot.Action.bindings[slot.Index].hasOverrides && Check(slot, slot.Action.bindings[slot.Index].effectivePath, out _) != BindingChange.Success)
throw new ArgumentException("Invalid or conflicting binding override.");
return true;
}
catch (Exception)
{
input.RemoveAllBindingOverrides();
input.LoadBindingOverridesFromJson(previous);
return false;
}
}
public void Reset() { Cancel(); input.RemoveAllBindingOverrides(); }
public BindingChange Check(BindingSlot target, string path, out BindingSlot conflict)
{
conflict = null;
if (string.Equals(path, "<Keyboard>/escape", StringComparison.OrdinalIgnoreCase) && target.Action.name != "Pause")
return BindingChange.Reserved;
if (!Supported(path))
return BindingChange.Unsupported;
foreach (var slot in slots)
if ((slot.Action != target.Action || slot.Index != target.Index)
&& string.Equals(slot.Action.bindings[slot.Index].effectivePath, path, StringComparison.OrdinalIgnoreCase))
{
conflict = slot;
return BindingChange.Conflict;
}
return BindingChange.Success;
}
public BindingChange Rebind(BindingSlot slot, string path, out BindingSlot conflict)
{
var result = Check(slot, path, out conflict);
if (result == BindingChange.Success)
slot.Action.ApplyBindingOverride(slot.Index, path);
return result;
}
public void Begin(BindingSlot slot, Action<BindingChange, BindingSlot> completed)
{
Cancel();
activeSlot = slot;
onCompleted = completed;
enabledActions.Clear();
foreach (var map in input.actionMaps)
foreach (var action in map.actions)
if (action.enabled)
enabledActions.Add(action);
input.Disable();
BindingChange result = BindingChange.Cancelled;
BindingSlot conflict = null;
operation = slot.Action.PerformInteractiveRebinding(slot.Index)
.WithExpectedControlType("Button")
.WithControlsHavingToMatchPath("<Keyboard>")
.WithControlsHavingToMatchPath("<Mouse>")
.WithControlsExcluding("<Mouse>/position")
.WithControlsExcluding("<Mouse>/delta")
.WithControlsExcluding("<Mouse>/scroll")
// Let the UI adapter distinguish a cancel-button click from a left-button binding.
.WithControlsExcluding("<Mouse>/leftButton")
.WithControlsExcluding("<Keyboard>/anyKey")
.WithControlsExcluding("<Keyboard>/shift")
.WithControlsExcluding("<Keyboard>/ctrl")
.WithControlsExcluding("<Keyboard>/alt")
.WithCancelingThrough("<Keyboard>/escape")
.OnMatchWaitForAnother(0)
.OnApplyBinding((_, path) => result = Rebind(slot, path, out conflict))
.OnCancel(_ => Finish(BindingChange.Cancelled, null))
.OnComplete(_ => Finish(result, conflict));
operation.Start();
}
// UI Toolkit also delivers keyboard/pointer events when a platform does not forward
// synthetic or accessibility input as raw device events. Both routes use the same validator.
public bool CapturePath(string path)
{
if (!IsRebinding)
return false;
var completed = onCompleted;
onCompleted = null;
var result = Rebind(activeSlot, path, out var conflict);
Cancel();
completed?.Invoke(result, conflict);
return true;
}
void Finish(BindingChange result, BindingSlot conflict)
{
var finished = operation;
var completed = onCompleted;
operation = null;
onCompleted = null;
activeSlot = null;
finished?.Dispose();
foreach (var action in enabledActions)
action.Enable();
enabledActions.Clear();
LastRebindFrame = Time.frameCount;
completed?.Invoke(result, conflict);
}
public void Cancel() => operation?.Cancel();
public void Dispose() => Cancel();
static bool Supported(string path)
{
if (string.IsNullOrEmpty(path))
return false;
if (path.StartsWith("<Keyboard>/", StringComparison.OrdinalIgnoreCase))
{
string name = path.Substring(11);
foreach (var keyControl in InputSystem.LoadLayout("Keyboard").controls)
if (keyControl.layout.ToString() == "Key" && string.Equals(keyControl.name.ToString(), name, StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
if (!path.StartsWith("<Mouse>/", StringComparison.OrdinalIgnoreCase))
return false;
string control = path.Substring(8).ToLowerInvariant();
return control == "leftbutton" || control == "rightbutton" || control == "middlebutton"
|| control == "forwardbutton" || control == "backbutton";
}
public string Display(BindingSlot slot)
{
string path = slot.Action.bindings[slot.Index].effectivePath;
string special = path?.ToLowerInvariant();
switch (special)
{
case "<mouse>/leftbutton": return GameLocalization.Text("settings.key.mouseLeft");
case "<mouse>/rightbutton": return GameLocalization.Text("settings.key.mouseRight");
case "<mouse>/middlebutton": return GameLocalization.Text("settings.key.mouseMiddle");
case "<mouse>/forwardbutton": return GameLocalization.Text("settings.key.mouseForward");
case "<mouse>/backbutton": return GameLocalization.Text("settings.key.mouseBack");
case "<keyboard>/space": return GameLocalization.Text("settings.key.space");
case "<keyboard>/leftshift": return GameLocalization.Text("settings.key.leftShift");
case "<keyboard>/rightshift": return GameLocalization.Text("settings.key.rightShift");
}
return slot.Action.GetBindingDisplayString(slot.Index);
}
public string Display(string actionName)
{
var names = new List<string>();
foreach (var slot in slots)
if (slot.Action.name == actionName)
names.Add(Display(slot));
return string.Join(" / ", names);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5338ce6eea517ea409e49e4303af1a00
@@ -0,0 +1,152 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
namespace Ironspore
{
public enum SettingsApplyResult { Saved, ConfirmDisplay, Failed }
// Runtime settings orchestration; UI owns its draft, while the store owns durable snapshots.
public sealed class SettingsService : IDisposable
{
readonly SettingsStore store;
readonly GameSettingsData defaults;
readonly float initialVolume;
readonly int initialQuality;
readonly Func<float> clock;
GameSettingsData current, preview;
float displayDeadline;
public InputBindingService Bindings { get; }
public GameSettingsData Current => current.Copy();
public GameSettingsData Defaults => defaults.Copy();
public bool ConfirmingDisplay => preview != null;
public int ConfirmationSeconds => Mathf.Max(0, Mathf.CeilToInt(displayDeadline - clock()));
public string Error => store.Error;
public float LookSensitivity => current.mouseSensitivity;
public float AimSensitivity => current.aimSensitivity;
public SettingsService(SettingsStore store, InputActionAsset input, GameSettingsData defaults, Func<float> clock = null)
{
this.store = store;
this.defaults = defaults.Copy();
this.clock = clock ?? (() => Time.realtimeSinceStartup);
initialVolume = AudioListener.volume;
initialQuality = QualitySettings.GetQualityLevel();
Bindings = new InputBindingService(input);
current = store.Data.Normalize(QualitySettings.names.Length);
if (!Bindings.TryLoad(current.bindingOverrides))
Debug.LogWarning("Invalid input overrides were ignored; using default controls.");
current.bindingOverrides = Bindings.Save();
ApplyRuntime(current, true);
}
public static GameSettingsData RuntimeDefaults() => new GameSettingsData
{
qualityLevel = Mathf.Min(2, QualitySettings.names.Length - 1),
resolutionWidth = Application.isEditor ? 1280 : Mathf.Max(640, Screen.width),
resolutionHeight = Application.isEditor ? 720 : Mathf.Max(360, Screen.height),
fullscreen = !Application.isEditor && Screen.fullScreen
};
public List<Vector2Int> Resolutions()
{
var sizes = new List<Vector2Int>();
foreach (var resolution in Screen.resolutions)
AddSize(sizes, resolution.width, resolution.height);
AddSize(sizes, 1280, 720);
AddSize(sizes, 1600, 900);
AddSize(sizes, 1920, 1080);
AddSize(sizes, current.resolutionWidth, current.resolutionHeight);
sizes.Sort((a, b) => a.x == b.x ? a.y.CompareTo(b.y) : a.x.CompareTo(b.x));
return sizes;
}
static void AddSize(List<Vector2Int> sizes, int width, int height)
{
var size = new Vector2Int(width, height);
if (width >= 640 && height >= 360 && !sizes.Contains(size))
sizes.Add(size);
}
public SettingsApplyResult Apply(GameSettingsData draft)
{
CancelDisplay();
var candidate = draft.Normalize(QualitySettings.names.Length);
candidate.bindingOverrides = Bindings.Save();
if (!candidate.SameDisplay(current))
{
preview = candidate;
displayDeadline = clock() + 15;
ApplyRuntime(preview, true);
return SettingsApplyResult.ConfirmDisplay;
}
return Save(candidate) ? SettingsApplyResult.Saved : SettingsApplyResult.Failed;
}
public bool ConfirmDisplay()
{
if (preview == null)
return false;
var candidate = preview;
preview = null;
if (Save(candidate))
return true;
ApplyRuntime(current, true);
Bindings.TryLoad(current.bindingOverrides);
return false;
}
bool Save(GameSettingsData candidate)
{
if (!store.Save(candidate))
return false;
current = candidate.Copy();
ApplyRuntime(current, false);
return true;
}
public bool Tick()
{
if (!ConfirmingDisplay || clock() < displayDeadline)
return false;
CancelDisplay();
return true;
}
public void CancelDisplay()
{
if (preview == null)
return;
preview = null;
ApplyRuntime(current, true);
}
public void DiscardDraft()
{
Bindings.Cancel();
CancelDisplay();
Bindings.TryLoad(current.bindingOverrides);
}
void ApplyRuntime(GameSettingsData data, bool display)
{
AudioListener.volume = data.masterVolume;
if (QualitySettings.GetQualityLevel() != data.qualityLevel)
QualitySettings.SetQualityLevel(data.qualityLevel, true);
if (display && !Application.isEditor)
Screen.SetResolution(data.resolutionWidth, data.resolutionHeight,
data.fullscreen ? FullScreenMode.ExclusiveFullScreen : FullScreenMode.Windowed);
}
public void Dispose()
{
DiscardDraft();
Bindings.Dispose();
#if UNITY_EDITOR
AudioListener.volume = initialVolume;
QualitySettings.SetQualityLevel(initialQuality, true);
#endif
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2ee4518453bf9384faa13484f6751b4e
@@ -0,0 +1,65 @@
using System;
using System.IO;
using System.Text;
using UnityEngine;
namespace Ironspore
{
// Settings have their own version and file; progression and recovery receipts stay in ProfileStore.
public sealed class SettingsStore
{
readonly string path;
readonly GameSettingsData defaults;
public GameSettingsData Data { get; private set; }
public string Error { get; private set; }
public SettingsStore(string path, GameSettingsData defaults = null)
{
this.path = path;
this.defaults = (defaults ?? new GameSettingsData()).Copy();
Data = Load(path) ?? Load(path + ".bak") ?? this.defaults.Copy();
}
GameSettingsData Load(string file)
{
try
{
if (!File.Exists(file))
return null;
var data = defaults.Copy();
JsonUtility.FromJsonOverwrite(File.ReadAllText(file), data);
return data.version == 1 ? data : null;
}
catch (Exception) { return null; }
}
public bool Save(GameSettingsData candidate)
{
if (candidate == null || candidate.version != 1)
{
Error = "Unsupported settings version.";
return false;
}
try
{
var snapshot = candidate.Copy();
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path)));
string temp = path + ".tmp";
using (var stream = new FileStream(temp, FileMode.Create, FileAccess.Write, FileShare.None))
{
byte[] bytes = Encoding.UTF8.GetBytes(JsonUtility.ToJson(snapshot, true));
stream.Write(bytes, 0, bytes.Length);
stream.Flush(true);
}
if (File.Exists(path))
File.Replace(temp, path, path + ".bak");
else
File.Move(temp, path);
Data = snapshot;
Error = null;
return true;
}
catch (Exception ex) { Error = ex.Message; return false; }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 95681a73f0c68c6488b2e98c655d67f3
+135
View File
@@ -0,0 +1,135 @@
using System.Linq;
using UnityEngine;
using UnityEngine.UIElements;
namespace Ironspore
{
// Field-only instruments. Weapon state and navigation targets belong to the simulation.
public sealed class CombatInstruments
{
readonly VisualElement field, map;
readonly GameSession game;
readonly Label primary, rounds, reload, secondary, cargo;
readonly string reloadHint;
float nextMapUpdate;
int displayedRounds = -1;
public CombatInstruments(VisualElement field, VisualElement vitals, GameSession game)
{
this.field = field;
this.game = game;
var nav = Panel("combat-nav");
Caption(nav, GameLocalization.Text("ui.combat.nav") + " N ↑", "combat-kicker");
map = new VisualElement();
map.AddToClassList("combat-map");
nav.Add(map);
map.generateVisualContent += DrawMap;
Caption(nav, GameLocalization.Text("ui.combat.legend"), "combat-small");
var weapon = Panel("combat-weapon");
primary = Caption(weapon, "", "combat-primary");
Caption(weapon, GameLocalization.Text("ui.combat.ammo"), "combat-kicker");
var row = new VisualElement();
row.AddToClassList("combat-rounds-row");
weapon.Add(row);
rounds = Caption(row, "", "combat-rounds");
reload = Caption(row, "", "combat-small");
secondary = Caption(weapon, "", "combat-secondary");
cargo = Caption(vitals, "", "combat-cargo");
reloadHint = GameLocalization.Text("ui.combat.reload", game.Settings.Bindings.Display("Reload"));
}
VisualElement Panel(string style)
{
var panel = new VisualElement();
panel.AddToClassList("combat-panel");
panel.AddToClassList(style);
field.Add(panel);
return panel;
}
static Label Caption(VisualElement parent, string text, string style)
{
var label = new Label(text);
label.AddToClassList(style);
parent.Add(label);
return label;
}
public void Refresh()
{
var pilot = game.Pilot;
field.EnableInClassList("cockpit-hud", pilot.FirstPerson);
bool redraw = Time.unscaledTime >= nextMapUpdate;
if (!redraw && pilot.Rounds == displayedRounds) return;
displayedRounds = pilot.Rounds;
primary.text = pilot.Primary ? pilot.Primary.LocalizedName : GameLocalization.Text("ui.combat.empty");
rounds.text = pilot.Primary ? pilot.Rounds.ToString() : "—";
rounds.EnableInClassList("ammo-low", pilot.Primary && pilot.Rounds <= pilot.Primary.magazineSize / 4);
reload.text = !pilot.Primary ? "" : pilot.ReloadRemaining > 0 ? GameLocalization.Text("ui.combat.loading", pilot.ReloadRemaining) : reloadHint;
var state = pilot.SecondaryCooldown > 0 ? GameLocalization.Text("ui.combat.charging", pilot.SecondaryCooldown) :
GameLocalization.Text(pilot.Energy < 15 ? "ui.combat.lowEnergy" : "ui.combat.ready");
var secondaryPart = game.Part(1, game.Run.equipment[1]);
secondary.text = secondaryPart ? secondaryPart.LocalizedName + " · " + state : GameLocalization.Text("ui.combat.empty");
cargo.text = GameLocalization.Text("ui.combat.cargo", game.Run.cargo.Sum(InventoryGrid.Area), game.Run.safeCase.Sum(InventoryGrid.Area));
if (redraw)
{
map.MarkDirtyRepaint();
nextMapUpdate = Time.unscaledTime + .075f;
}
}
void DrawMap(MeshGenerationContext context)
{
var pilot = game.Pilot;
if (!pilot || !game.InField) return;
var painter = context.painter2D;
var rect = map.contentRect;
// Keep north up and use one scale on both axes. Include the pilot past the yard edges.
var position = pilot.transform.position;
var scale = Mathf.Min(rect.width / Mathf.Max(90, Mathf.Abs(position.x) * 2 + 8),
rect.height / Mathf.Max(100, Mathf.Abs(position.z - 8) * 2 + 8));
Vector2 Project(float x, float z) => rect.center + new Vector2(x, 8 - z) * scale;
void Line(Vector2 a, Vector2 b)
{
painter.BeginPath(); painter.MoveTo(a); painter.LineTo(b); painter.Stroke();
}
painter.lineWidth = 1;
painter.strokeColor = new Color(.15f, .28f, .30f);
for (int x = -30; x <= 30; x += 10) Line(Project(x, -40), Project(x, 56));
for (int z = -40; z <= 50; z += 10) Line(Project(-35, z), Project(35, z));
painter.lineWidth = 2;
painter.strokeColor = new Color(.33f, .49f, .5f);
Line(Project(-35, -38), Project(-35, 58));
Line(Project(35, -38), Project(35, 58));
painter.fillColor = painter.strokeColor;
for (int i = 0; i < game.World.Cover.Count; i++)
{
var box = game.World.Cover[i];
painter.BeginPath(); painter.MoveTo(Project(box.xMin, box.yMin));
painter.LineTo(Project(box.xMax, box.yMin)); painter.LineTo(Project(box.xMax, box.yMax));
painter.LineTo(Project(box.xMin, box.yMax)); painter.ClosePath(); painter.Fill();
}
var target = game.World.NavigationTarget;
if (target)
{
var pos = Project(target.position.x, target.position.z);
painter.strokeColor = new Color(.45f, .84f, 1);
painter.BeginPath(); painter.MoveTo(pos + Vector2.up * 6);
painter.LineTo(pos + Vector2.right * 6); painter.LineTo(pos + Vector2.down * 6);
painter.LineTo(pos + Vector2.left * 6); painter.ClosePath(); painter.Stroke();
}
var pad = game.World.ExtractionPosition;
painter.strokeColor = game.World.TransportReady ? Color.green : new Color(1, .72f, .36f);
painter.BeginPath(); painter.Arc(Project(pad.x, pad.z), 6, 0, 360); painter.Stroke();
var origin = Project(position.x, position.z);
var radians = pilot.Heading * Mathf.Deg2Rad;
var forward = new Vector2(Mathf.Sin(radians), -Mathf.Cos(radians));
var right = new Vector2(-forward.y, forward.x);
painter.fillColor = new Color(.43f, 1, .80f);
painter.BeginPath(); painter.MoveTo(origin + forward * 9);
painter.LineTo(origin - forward * 6 + right * 5); painter.LineTo(origin - forward * 6 - right * 5);
painter.ClosePath(); painter.Fill();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6dc5857341153c740bcee60a2f187f06
+76 -26
View File
@@ -1,20 +1,22 @@
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;
Label mission, prompt, status, notice, telemetry;
ProgressBar armor, energy, cargo;
VisualElement cross;
VisualElement cross, controlsHint;
CombatInstruments instruments;
enum View
{
Hangar, Field, Cargo, Part, Routes, Pause
Hangar, Field, Cargo, Part, Routes, Pause, Settings
}
View view;
int partSlot, partIndex;
@@ -24,6 +26,11 @@ namespace Ironspore
int lastLanguageActivationFrame = -1;
int lastLanguageNavigationFrame = -1;
Label hangarNotice;
SettingsScreen settingsScreen;
int menuInputFrame = -1;
public bool SettingsOpen => view == View.Settings;
public bool MenuInputConsumedThisFrame => menuInputFrame == Time.frameCount;
string K(string action) => game.Settings.Bindings.Display(action);
static string L(string key, params object[] args) => GameLocalization.Text(key, args);
public void Initialize(GameSession session)
{
@@ -37,8 +44,12 @@ 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;
settingsScreen = new SettingsScreen(game.Settings, () => game.Action("Pause").WasPressedThisFrame());
}
void HandleLanguageKey(KeyDownEvent evt)
{
@@ -106,10 +117,13 @@ namespace Ironspore
}
void Clear()
{
ResetDrag();
settingsScreen?.Close();
root.Clear();
mission = prompt = status = notice = telemetry = null;
armor = energy = cargo = null;
cross = null;
cross = controlsHint = null;
instruments = null;
hangarNotice = null;
languageControl = languageMenu = languageDismiss = null;
languageButton = null;
@@ -137,6 +151,9 @@ namespace Ironspore
case View.Pause:
ShowPause();
break;
case View.Settings:
settingsScreen.RefreshLocale();
break;
}
}
void AddLanguageControl(VisualElement page)
@@ -225,7 +242,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++)
@@ -242,19 +259,35 @@ namespace Ironspore
if (!game.StartingPartsUnlocked())
Text(content, L("ui.home.locked"), "warning");
Button(controls, L("ui.home.range"), game.StartTrial);
Button(controls, L("settings.title"), ShowSettings).name = "home-settings";
if (game.Store.Data.run.active)
Button(controls, L("ui.home.resume"), game.Resume, "primary");
Text(content, L("ui.home.moveHint"), "hint");
Text(content, L("ui.home.fireHint"), "hint");
Text(content, L("ui.home.moveHint", K("Move"), K("View"), K("Boost"), K("Jump")), "hint");
Text(content, L("ui.home.fireHint", K("Fire"), K("Aim"), K("Secondary"), K("Guard"), K("Interact")), "hint");
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();
@@ -273,7 +306,7 @@ namespace Ironspore
var goal = Element(field, "mission");
Text(goal, L("ui.hud.operation"), "section");
mission = Text(goal, "", "mission-text");
telemetry = Text(field, "", "telemetry");
telemetry = Text(goal, "", "combat-extraction");
cross = Element(field, "crosshair");
cross.style.position = Position.Absolute;
cross.style.left = Length.Percent(50);
@@ -294,16 +327,17 @@ namespace Ironspore
prompt = Text(field, "", "interact");
notice = Text(field, "", "notice");
var bottom = Element(field, "bottom");
instruments = new CombatInstruments(field, bottom, game);
armor = new ProgressBar { title = L("ui.hud.armor", 0), lowValue = 0, highValue = 100 };
armor.AddToClassList("armor");
bottom.Add(armor);
energy = new ProgressBar { title = L("ui.hud.energy", 0), lowValue = 0, highValue = 100 };
energy.AddToClassList("energy");
bottom.Add(energy);
cargo = new ProgressBar { title = L("ui.hud.podReady"), lowValue = 0, highValue = 3 };
cargo = new ProgressBar { title = L("ui.hud.podReady", K("Inventory")), lowValue = 0, highValue = 3 };
cargo.AddToClassList("cargo-progress");
bottom.Add(cargo);
Text(bottom, L("ui.hud.hint"), "hint");
controlsHint = Text(bottom, L("ui.hud.hint", K("View"), K("Boost"), K("Jump"), K("Secondary"), K("Guard"), K("Interact"), K("Inventory"), K("Cargo"), K("Pause")), "hint");
}
VisualElement Overlay(string heading, string body)
{
@@ -314,19 +348,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 +358,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);
@@ -356,12 +377,33 @@ namespace Ironspore
var panel = Overlay(L("ui.pause.title"), L("ui.pause.help"));
view = View.Pause;
Button(panel, L("ui.pause.resume"), game.CloseMenu, "primary");
Button(panel, L("settings.title"), ShowSettings).name = "pause-settings";
if (game.Trial)
Button(panel, L("ui.pause.range"), () => { game.CloseMenu(); game.EndSortie(true); });
Button(panel, L("ui.pause.quit"), () => Application.Quit());
}
public void ShowSettings()
{
bool returnToPause = view == View.Pause;
Clear();
view = View.Settings;
menuInputFrame = Time.frameCount;
settingsScreen.Open(root, () =>
{
if (returnToPause) ShowPause();
else ShowHangar();
root.Q<Button>(returnToPause ? "pause-settings" : "home-settings")?.Focus();
});
}
public void CancelSettingsInput() => settingsScreen.CancelInput();
void Update()
{
if (SettingsOpen)
{
menuInputFrame = Time.frameCount;
settingsScreen.Tick();
return;
}
if (view == View.Hangar)
{
RefreshHangarNotice();
@@ -378,6 +420,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;
@@ -400,7 +443,7 @@ namespace Ironspore
if (cargo != null)
{
cargo.value = game.CargoProgress;
cargo.title = L(run.podUsed ? "ui.hud.podUsed" : game.CargoProgress > 0 ? "ui.hud.podPreparing" : "ui.hud.podReady");
cargo.title = L(run.podUsed ? "ui.hud.podUsed" : game.CargoProgress > 0 ? "ui.hud.podPreparing" : "ui.hud.podReady", K("Inventory"));
}
if (prompt != null)
{
@@ -410,12 +453,19 @@ 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 = game.World.ExtractionRemaining < 0 ? L("ui.hud.extractionAvailable") :
game.World.TransportReady ? L("ui.hud.transportReady") : L("ui.hud.transportEta", Mathf.CeilToInt(game.World.ExtractionRemaining));
if (instruments != null)
{
instruments.Refresh();
controlsHint.style.display = game.Action("Aim").IsPressed() ? DisplayStyle.None : DisplayStyle.Flex;
}
if (cross != null)
cross.style.opacity = p.RecentHit ? 1 : .65f;
}
void OnDestroy()
{
settingsScreen?.Close();
GameLocalization.Changed -= RefreshLocale;
if (panelSettings)
Destroy(panelSettings);
+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", K("Cargo")));
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
@@ -0,0 +1,40 @@
using UnityEngine;
namespace Ironspore
{
// Adapts UI Toolkit events only; gameplay continues to use Input System actions.
public static class SettingsInputCapture
{
public static string KeyboardPath(KeyCode key)
{
string name = key.ToString();
if (key >= KeyCode.Alpha0 && key <= KeyCode.Alpha9)
name = ((int)key - (int)KeyCode.Alpha0).ToString();
else if (name.StartsWith("Keypad"))
name = "numpad" + name.Substring(6);
switch (key)
{
case KeyCode.Return: name = "enter"; break;
case KeyCode.LeftControl: name = "leftCtrl"; break;
case KeyCode.RightControl: name = "rightCtrl"; break;
case KeyCode.LeftWindows: case KeyCode.LeftCommand: name = "leftMeta"; break;
case KeyCode.RightWindows: case KeyCode.RightCommand: name = "rightMeta"; break;
case KeyCode.Print: name = "printScreen"; break;
}
return "<Keyboard>/" + name.ToLowerInvariant();
}
public static string MousePath(int button)
{
switch (button)
{
case 0: return "<Mouse>/leftButton";
case 1: return "<Mouse>/rightButton";
case 2: return "<Mouse>/middleButton";
case 3: return "<Mouse>/backButton";
case 4: return "<Mouse>/forwardButton";
default: return "";
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c39856b7898db4a4e905faa29a24b255
+444
View File
@@ -0,0 +1,444 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UIElements;
namespace Ironspore
{
// Owns a settings draft and its UI. It does not write files or mutate gameplay state.
public sealed class SettingsScreen
{
readonly SettingsService settings;
readonly Func<bool> backPressed;
readonly List<VisualElement> focusOrder = new List<VisualElement>();
readonly Dictionary<Button, Action> buttonActions = new Dictionary<Button, Action>();
VisualElement parent, screen;
ScrollView scroll;
Label countdown;
Action closed;
GameSettingsData draft;
BindingSlot capture;
string messageKey;
object[] messageArgs = Array.Empty<object>();
int tab, lastKeyFrame = -1;
bool open;
static string L(string key, params object[] args) => GameLocalization.Text(key, args);
public SettingsScreen(SettingsService settings, Func<bool> backPressed)
{
this.settings = settings;
this.backPressed = backPressed;
}
public void Open(VisualElement parent, Action closed)
{
this.parent = parent;
this.closed = closed;
draft = settings.Current;
messageKey = null;
open = true;
Draw();
}
public void Close()
{
if (!open)
return;
open = false;
capture = null;
settings.DiscardDraft();
screen?.RemoveFromHierarchy();
screen = null;
scroll = null;
closed = null;
focusOrder.Clear();
buttonActions.Clear();
}
public void RefreshLocale()
{
if (!open) return;
string focusedName = (screen.focusController.focusedElement as VisualElement)?.name;
Draw();
if (!string.IsNullOrEmpty(focusedName)) FocusAfterDispatch(screen.Q(focusedName));
}
public void CancelInput() => settings.Bindings.Cancel();
void Draw()
{
Vector2 offset = scroll?.scrollOffset ?? Vector2.zero;
screen?.RemoveFromHierarchy();
focusOrder.Clear();
buttonActions.Clear();
screen = Element(parent, "settings-screen", "settings-screen");
screen.RegisterCallback<KeyDownEvent>(evt =>
{
if (HandleKey(evt.keyCode, evt.shiftKey))
evt.StopImmediatePropagation();
}, TrickleDown.TrickleDown);
screen.RegisterCallback<PointerDownEvent>(CapturePointer, TrickleDown.TrickleDown);
var panel = Element(screen, "settings-panel");
var header = Element(panel, "settings-header");
var title = Element(header, "settings-title-group");
Text(title, L("settings.title"), "heading");
Text(title, L("settings.help"), "settings-help");
var locales = GameLocalization.Catalog.locales;
var names = new List<string>();
foreach (var locale in locales)
names.Add(locale.nativeName);
int languageIndex = Array.FindIndex(locales, x => x.code == GameLocalization.CurrentCode);
var language = Dropdown(header, "settings.language", names, languageIndex, index => GameLocalization.SelectLocale(locales[index].code));
language.name = "settings-language";
language.AddToClassList("settings-language");
var body = Element(panel, "settings-body");
var navigation = Element(body, "settings-navigation");
string[] tabs = { "settings.audio", "settings.display", "settings.controls" };
for (int i = 0; i < tabs.Length; i++)
{
int choice = i;
var button = AddButton(navigation, L(tabs[i]), () => { tab = choice; scroll = null; Draw(); }, "settings-tab");
button.name = "settings-tab-" + i;
if (tab == i)
button.AddToClassList("selected");
}
scroll = new ScrollView(ScrollViewMode.Vertical) { horizontalScrollerVisibility = ScrollerVisibility.Hidden };
scroll.AddToClassList("settings-scroll");
body.Add(scroll);
if (tab == 0)
Audio(scroll);
else if (tab == 1)
Display(scroll);
else
Controls(scroll);
var renderedScroll = scroll;
renderedScroll.schedule.Execute(() => { if (renderedScroll.panel != null) renderedScroll.scrollOffset = offset; });
var status = Text(panel, string.IsNullOrEmpty(messageKey) ? "" : L(messageKey, messageArgs), "settings-status");
status.name = "settings-status";
var footer = Element(panel, "settings-footer");
var reset = AddButton(footer, L("settings.reset"), Reset, "button");
reset.name = "settings-reset";
var footerActions = Element(footer, "settings-footer-actions");
AddButton(footerActions, L("settings.back"), Back, "button").name = "settings-back";
AddButton(footerActions, L("settings.apply"), Apply, "primary").name = "settings-apply";
if (settings.ConfirmingDisplay)
{
header.SetEnabled(false);
body.SetEnabled(false);
footer.SetEnabled(false);
var dialog = Dialog("settings.display.confirm");
countdown = Text(dialog, L("settings.display.countdown", settings.ConfirmationSeconds), "body");
FocusAfterDispatch(AddButton(dialog, L("settings.display.keep"), ConfirmDisplay, "primary"));
AddButton(dialog, L("settings.display.revert"), RevertDisplay, "button");
}
else if (capture != null)
{
header.SetEnabled(false);
body.SetEnabled(false);
footer.SetEnabled(false);
var dialog = Dialog("settings.bind.waiting");
Text(dialog, L("settings.bind.prompt", L(capture.LabelKey)), "body");
var cancel = AddButton(dialog, L("settings.bind.cancel"), settings.Bindings.Cancel, "button");
cancel.name = "settings-capture-cancel";
FocusAfterDispatch(cancel);
}
else
FocusAfterDispatch(navigation.Q<Button>("settings-tab-" + tab));
}
static void FocusAfterDispatch(VisualElement element)
{
// Popup and button dispatch may restore focus to the old, detached control afterwards.
if (element != null)
element.schedule.Execute(() => { if (element.panel != null && element.enabledInHierarchy) element.Focus(); });
}
void Audio(VisualElement content)
{
Text(content, L("settings.audio"), "section");
Slider(content, "settings.volume", draft.masterVolume, 0, 1, .01f,
value => draft.masterVolume = value, value => L("settings.percent", Mathf.RoundToInt(value * 100)));
Text(content, L("settings.volumeHelp"), "hint");
}
void Display(VisualElement content)
{
Text(content, L("settings.display"), "section");
string[] tiers = QualitySettings.names;
var qualityNames = new List<string>();
foreach (string tier in tiers)
{
switch (tier)
{
case "Very Low": qualityNames.Add(L("settings.quality.veryLow")); break;
case "Low": qualityNames.Add(L("settings.quality.low")); break;
case "Medium": qualityNames.Add(L("settings.quality.medium")); break;
case "High": qualityNames.Add(L("settings.quality.high")); break;
case "Very High": qualityNames.Add(L("settings.quality.veryHigh")); break;
case "Ultra": qualityNames.Add(L("settings.quality.ultra")); break;
default: qualityNames.Add(tier); break;
}
}
Dropdown(content, "settings.quality", qualityNames, draft.qualityLevel, index => draft.qualityLevel = index).name = "settings-quality";
var resolutions = settings.Resolutions();
var choices = new List<string>();
int selected = 0;
for (int i = 0; i < resolutions.Count; i++)
{
var size = resolutions[i];
choices.Add(size.x + " × " + size.y);
if (size.x == draft.resolutionWidth && size.y == draft.resolutionHeight)
selected = i;
}
Dropdown(content, "settings.resolution", choices, selected, index =>
{
draft.resolutionWidth = resolutions[index].x;
draft.resolutionHeight = resolutions[index].y;
}).name = "settings-resolution";
Dropdown(content, "settings.windowMode", new List<string> { L("settings.windowed"), L("settings.fullscreen") }, draft.fullscreen ? 1 : 0,
index => draft.fullscreen = index == 1).name = "settings-window-mode";
Text(content, L("settings.displayHelp"), "hint");
}
void Controls(VisualElement content)
{
Text(content, L("settings.mouse"), "section");
Slider(content, "settings.sensitivity", draft.mouseSensitivity, .1f, 5, .05f,
value => draft.mouseSensitivity = value, value => L("settings.multiplier", value.ToString("0.00", GameLocalization.Culture)));
Slider(content, "settings.aimSensitivity", draft.aimSensitivity, .1f, 2, .05f,
value => draft.aimSensitivity = value, value => L("settings.multiplier", value.ToString("0.00", GameLocalization.Culture)));
Text(content, L("settings.bindings"), "section");
Text(content, L("settings.bind.help"), "hint");
foreach (var slot in settings.Bindings.Slots)
{
var row = Element(content, "settings-binding-row");
Text(row, L(slot.LabelKey), "settings-binding-label");
var button = AddButton(row, settings.Bindings.Display(slot), () => BeginRebind(slot), "settings-binding");
button.name = "binding-" + slot.Action.name.ToLowerInvariant() + "-" + slot.Index;
button.tooltip = L("settings.bind.prompt", L(slot.LabelKey));
}
}
void BeginRebind(BindingSlot slot)
{
capture = slot;
settings.Bindings.Begin(slot, (result, conflict) =>
{
if (!open)
return;
capture = null;
draft.bindingOverrides = settings.Bindings.Save();
string key = result == BindingChange.Success ? "settings.bind.changed"
: result == BindingChange.Cancelled ? "settings.bind.cancelled"
: result == BindingChange.Conflict ? "settings.bind.conflict"
: result == BindingChange.Reserved ? "settings.bind.reserved" : "settings.bind.unsupported";
Message(key, result == BindingChange.Conflict ? L(conflict.LabelKey) : L(slot.LabelKey));
Draw();
});
Draw();
}
void Apply()
{
var result = settings.Apply(draft);
if (result == SettingsApplyResult.Saved)
{
draft = settings.Current;
Message("settings.saved");
}
else if (result == SettingsApplyResult.Failed)
Message("settings.saveFailed");
Draw();
}
void ConfirmDisplay()
{
bool saved = settings.ConfirmDisplay();
draft = settings.Current;
Message(saved ? "settings.saved" : "settings.saveFailed");
Draw();
}
void RevertDisplay()
{
settings.CancelDisplay();
var saved = settings.Current;
draft.resolutionWidth = saved.resolutionWidth;
draft.resolutionHeight = saved.resolutionHeight;
draft.fullscreen = saved.fullscreen;
Message("settings.display.reverted");
Draw();
}
void Reset()
{
draft = settings.Defaults;
settings.Bindings.Reset();
Message("settings.resetHint");
Draw();
}
void Back()
{
if (settings.Bindings.IsRebinding)
{
settings.Bindings.Cancel();
return;
}
if (settings.ConfirmingDisplay)
{
RevertDisplay();
return;
}
var callback = closed;
Close();
callback?.Invoke();
}
public void Tick()
{
if (!open)
return;
if (settings.Tick())
RevertDisplay();
if (settings.ConfirmingDisplay && countdown != null)
countdown.text = L("settings.display.countdown", settings.ConfirmationSeconds);
if (settings.Bindings.SuppressMenuInput)
return;
var keyboard = Keyboard.current;
if (keyboard != null)
{
if (keyboard.escapeKey.wasPressedThisFrame) HandleKey(KeyCode.Escape, false);
else if (keyboard.tabKey.wasPressedThisFrame) HandleKey(KeyCode.Tab, keyboard.shiftKey.isPressed);
else if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame) HandleKey(KeyCode.Return, false);
else if (keyboard.spaceKey.wasPressedThisFrame) HandleKey(KeyCode.Space, false);
else if (keyboard.leftArrowKey.wasPressedThisFrame) HandleKey(KeyCode.LeftArrow, false);
else if (keyboard.rightArrowKey.wasPressedThisFrame) HandleKey(KeyCode.RightArrow, false);
else if (keyboard.upArrowKey.wasPressedThisFrame) HandleKey(KeyCode.UpArrow, false);
else if (keyboard.downArrowKey.wasPressedThisFrame) HandleKey(KeyCode.DownArrow, false);
}
if (backPressed() && lastKeyFrame != Time.frameCount)
HandleKey(KeyCode.Escape, false);
}
bool HandleKey(KeyCode key, bool shift)
{
if (settings.Bindings.IsRebinding)
{
if (key == KeyCode.Escape) settings.Bindings.Cancel();
else if (key != KeyCode.None) settings.Bindings.CapturePath(SettingsInputCapture.KeyboardPath(key));
return true;
}
if (settings.Bindings.SuppressMenuInput)
return true;
if (lastKeyFrame == Time.frameCount)
return true;
var focused = screen.focusController.focusedElement as VisualElement;
int index = focusOrder.FindIndex(x => x == focused || (focused != null && x.Contains(focused)));
if (key == KeyCode.Escape)
Back();
else if (key == KeyCode.Tab)
{
int direction = shift ? -1 : 1;
for (int i = 0; i < focusOrder.Count; i++)
{
index = (index + direction + focusOrder.Count) % focusOrder.Count;
if (focusOrder[index].enabledInHierarchy)
{
focusOrder[index].Focus();
if (scroll.Contains(focusOrder[index])) scroll.ScrollTo(focusOrder[index]);
break;
}
}
}
else if ((key == KeyCode.Return || key == KeyCode.KeypadEnter || key == KeyCode.Space) && focused is Button button && buttonActions.TryGetValue(button, out var action))
action();
else if (index >= 0 && (key == KeyCode.LeftArrow || key == KeyCode.RightArrow || key == KeyCode.UpArrow || key == KeyCode.DownArrow))
{
int direction = key == KeyCode.LeftArrow || key == KeyCode.DownArrow ? -1 : 1;
if (focusOrder[index] is Slider slider)
slider.value += direction * (float)slider.userData;
else if (focusOrder[index] is DropdownField dropdown)
dropdown.index = (dropdown.index + direction + dropdown.choices.Count) % dropdown.choices.Count;
else
return false;
}
else
return false;
lastKeyFrame = Time.frameCount;
return true;
}
void CapturePointer(PointerDownEvent evt)
{
if (!settings.Bindings.IsRebinding)
return;
var cancel = screen.Q<Button>("settings-capture-cancel");
var target = evt.target as VisualElement;
if (cancel != null && target != null && (target == cancel || cancel.Contains(target)))
settings.Bindings.Cancel();
else
settings.Bindings.CapturePath(SettingsInputCapture.MousePath(evt.button));
evt.StopImmediatePropagation();
}
void Message(string key, params object[] args) { messageKey = key; messageArgs = args; }
VisualElement Dialog(string titleKey)
{
var shade = Element(screen, "settings-dialog-shade");
var dialog = Element(shade, "settings-dialog");
Text(dialog, L(titleKey), "heading");
return dialog;
}
static VisualElement Element(VisualElement parent, string css, string name = null)
{
var element = new VisualElement { name = name };
element.AddToClassList(css);
parent.Add(element);
return element;
}
static Label Text(VisualElement parent, string value, string css)
{
var label = new Label(value);
label.AddToClassList(css);
parent.Add(label);
return label;
}
Button AddButton(VisualElement parent, string label, Action action, string css)
{
var button = new Button(action) { text = label };
button.AddToClassList(css);
parent.Add(button);
focusOrder.Add(button);
buttonActions.Add(button, action);
return button;
}
DropdownField Dropdown(VisualElement parent, string key, List<string> choices, int index, Action<int> changed)
{
var dropdown = new DropdownField(L(key), choices, Mathf.Clamp(index, 0, choices.Count - 1));
dropdown.AddToClassList("settings-dropdown");
dropdown.RegisterValueChangedCallback(evt => changed(dropdown.index));
parent.Add(dropdown);
focusOrder.Add(dropdown);
return dropdown;
}
void Slider(VisualElement parent, string key, float value, float min, float max, float step, Action<float> changed, Func<float, string> format)
{
var row = Element(parent, "settings-slider-row");
var heading = Element(row, "settings-slider-heading");
Text(heading, L(key), "settings-label");
var readout = Text(heading, format(value), "settings-value");
var slider = new Slider(min, max) { value = value, name = key, userData = step };
slider.AddToClassList("settings-slider");
slider.RegisterValueChangedCallback(evt => { changed(evt.newValue); readout.text = format(evt.newValue); });
row.Add(slider);
focusOrder.Add(slider);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3fbae86f0fc79404187221fe6d6f24fb
File diff suppressed because one or more lines are too long
+68
View File
@@ -53,3 +53,71 @@
.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); }
.settings-screen { position:absolute; left:0; right:0; top:0; bottom:0; align-items:center; justify-content:center; background-color:rgba(2,9,13,.88); color:rgb(222,235,230); }
.settings-panel { width:1120px; max-width:94%; height:900px; max-height:92%; padding:28px 32px; background-color:rgb(10,25,31); border-width:1px; border-color:rgb(72,132,121); }
.settings-header { flex-direction:row; align-items:center; justify-content:space-between; padding-bottom:20px; border-bottom-width:1px; border-bottom-color:rgb(45,78,79); }
.settings-title-group { flex-grow:1; flex-shrink:1; }
.settings-header .heading { margin-bottom:8px; }
.settings-help { font-size:15px; color:rgb(153,184,181); white-space:normal; margin-right:18px; }
.settings-language { width:250px; flex-shrink:0; }
.settings-body { flex-direction:row; flex-grow:1; min-height:0; margin-top:20px; }
.settings-navigation { width:195px; flex-shrink:0; padding-right:18px; border-right-width:1px; border-right-color:rgb(45,78,79); }
.settings-tab { min-height:54px; margin:0 0 10px 0; padding:12px; -unity-text-align:middle-left; font-size:17px; color:rgb(205,226,219); background-color:rgb(20,41,47); border-width:1px; border-color:rgb(48,77,79); white-space:normal; }
.settings-tab.selected { color:rgb(150,247,214); background-color:rgb(38,77,72); border-color:rgb(104,210,188); }
.settings-tab:hover,.settings-tab:focus,.settings-binding:hover,.settings-binding:focus { background-color:rgb(43,79,78); border-color:rgb(127,240,204); }
.settings-scroll { flex-grow:1; flex-shrink:1; min-width:0; padding-left:24px; padding-right:10px; }
.settings-scroll .section { margin-top:4px; margin-bottom:18px; font-size:16px; }
.settings-scroll .hint { margin-bottom:20px; font-size:15px; }
.settings-scroll .unity-scroller--vertical { width:14px; margin-left:8px; }
.settings-scroll .unity-scroller__low-button,.settings-scroll .unity-scroller__high-button { display:none; }
.settings-scroll .unity-scroller .unity-base-slider__tracker { background-color:rgb(22,46,52); border-width:0; }
.settings-scroll .unity-scroller .unity-base-slider__dragger { background-color:rgb(67,121,111); border-width:0; border-radius:3px; }
.settings-dropdown { min-height:48px; margin:0 0 18px 0; font-size:17px; flex-shrink:0; }
.settings-dropdown .unity-base-field__label { min-width:170px; white-space:normal; color:rgb(204,225,217); -unity-text-align:middle-left; }
.settings-language .unity-base-field__label { min-width:0; margin-right:10px; }
.settings-dropdown .unity-base-field__input { min-width:0; min-height:42px; padding:6px 12px; color:rgb(225,241,234); background-color:rgb(25,48,53); border-width:1px; border-color:rgb(74,119,111); }
.settings-dropdown .unity-base-popup-field__text { white-space:normal; }
.settings-slider-row { margin-bottom:24px; flex-shrink:0; }
.settings-slider-heading { flex-direction:row; justify-content:space-between; margin-bottom:10px; }
.settings-label { font-size:17px; color:rgb(204,225,217); white-space:normal; }
.settings-value { font-size:18px; color:rgb(139,246,215); min-width:88px; -unity-text-align:middle-right; }
.settings-slider { height:36px; margin:0 4px; }
.settings-slider .unity-base-slider__tracker { height:5px; background-color:rgb(61,112,108); }
.settings-slider .unity-base-slider__dragger { width:18px; height:24px; margin-top:-10px; background-color:rgb(126,233,199); border-width:1px; border-color:rgb(187,255,230); }
.settings-binding-row { flex-direction:row; align-items:center; justify-content:space-between; min-height:54px; margin-bottom:6px; flex-shrink:0; border-bottom-width:1px; border-bottom-color:rgb(27,51,58); }
.settings-binding-label { flex-grow:1; flex-shrink:1; font-size:17px; color:rgb(204,225,217); white-space:normal; margin-right:16px; }
.settings-binding { width:245px; min-height:42px; margin:0 0 8px 0; padding:8px 12px; flex-shrink:0; color:rgb(139,246,215); background-color:rgb(25,48,53); border-width:1px; border-color:rgb(58,91,91); font-size:16px; white-space:normal; }
.settings-status { min-height:44px; margin-top:16px; font-size:15px; white-space:normal; color:rgb(255,205,127); }
.settings-footer { flex-direction:row; justify-content:space-between; align-items:center; padding-top:10px; border-top-width:1px; border-top-color:rgb(45,78,79); flex-shrink:0; }
.settings-footer-actions { flex-direction:row; }
.settings-footer .button,.settings-footer .primary { min-height:48px; min-width:145px; margin-right:0; margin-left:10px; }
.settings-screen .button:focus,.settings-screen .primary:focus,.settings-dropdown:focus .unity-base-field__input,.settings-slider:focus { border-color:rgb(183,255,228); }
.settings-dialog-shade { position:absolute; left:0; top:0; right:0; bottom:0; align-items:center; justify-content:center; background-color:rgba(0,6,10,.86); }
.settings-dialog { width:720px; max-width:90%; padding:34px; background-color:rgb(14,34,40); border-width:1px; border-color:rgb(104,210,188); }
.settings-dialog .heading { font-size:25px; white-space:normal; }
.settings-dialog .button,.settings-dialog .primary { min-height:48px; }
.field .topbar { width:530px; margin:30px 42px 0; padding:8px 16px; align-items:center; background-color:rgba(9,23,29,.88); }
.field .mission { top:88px; }
.combat-panel { position:absolute; padding:14px 18px; background-color:rgba(9,23,29,.92); border-top-width:2px; border-top-color:rgb(100,212,184); }
.combat-nav { top:36px; right:42px; width:232px; }
.combat-map { height:168px; background-color:rgb(14,34,40); margin:6px 0; }
.combat-weapon { bottom:34px; right:42px; min-width:330px; }
.cockpit-hud .combat-nav,.cockpit-hud .combat-weapon { right:64px; }
.cockpit-hud .bottom { left:64px; }
.combat-primary { font-size:25px; -unity-font-style:bold; margin-bottom:6px; }
.combat-kicker { color:rgb(130,218,198); font-size:15px; letter-spacing:1px; }
.combat-rounds-row { flex-direction:row; align-items:center; justify-content:space-between; }
.combat-rounds { font-size:52px; -unity-font-style:bold; color:rgb(228,248,240); }
.ammo-low,.combat-extraction { color:rgb(255,201,119); }
.combat-extraction { margin-top:8px; font-size:16px; }
.combat-small,.combat-secondary { font-size:16px; color:rgb(194,219,216); }
.combat-secondary { border-top-width:1px; border-top-color:rgb(55,88,88); padding-top:8px; }
.field .bottom { right:auto; width:580px; padding:12px 16px; background-color:rgba(9,23,29,.86); }
.combat-cargo { margin-bottom:4px; color:rgb(173,225,208); font-size:16px; }
@@ -0,0 +1,87 @@
using System;
using System.IO;
using System.Reflection;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UIElements;
namespace Ironspore.Tests
{
public sealed class EquipmentHudTests
{
[Test]
public void EmptyWeaponSlotsUpdateAmmoAndGridCapacityWithoutLosingTheProtectedWeapon()
{
string folder = Path.Combine(Path.GetTempPath(), "IronsporeHudTests", Guid.NewGuid().ToString("N"));
var host = new GameObject("EquipmentHudTest");
host.SetActive(false);
InputActionAsset input = null;
ChassisDefinition frame = null;
SettingsService settings = null;
var catalog = AssetDatabase.LoadAssetAtPath<LocalizationCatalog>("Assets/Game/Localization/LocalizationCatalog.asset");
GameLocalization.Initialize(catalog);
string locale = GameLocalization.CurrentCode;
try
{
GameLocalization.SelectLocale("en", false);
var store = new ProfileStore(Path.Combine(folder, "profile-v2.json"));
Assert.IsTrue(store.Begin(0, new[] { 0, 0, 0, 0 }, 100, 1));
var game = host.AddComponent<GameSession>();
var pilot = host.AddComponent<MechPilot>();
var primary = AssetDatabase.LoadAssetAtPath<EquipmentDefinition>("Assets/Game/Data/Equipment/rapid.asset");
var secondary = AssetDatabase.LoadAssetAtPath<EquipmentDefinition>("Assets/Game/Data/Equipment/guided.asset");
Assert.IsNotNull(primary);
Assert.IsNotNull(secondary);
frame = ScriptableObject.CreateInstance<ChassisDefinition>();
game.chassis = new[] { frame };
game.equipment = new[] { primary, secondary };
input = InputActionAsset.FromJson(File.ReadAllText("Assets/Game/Input/GameInput.inputactions"));
settings = new SettingsService(new SettingsStore(Path.Combine(folder, "settings.json")), input, new GameSettingsData());
typeof(GameSession).GetProperty("Store").SetValue(game, store);
typeof(GameSession).GetProperty("Pilot").SetValue(game, pilot);
typeof(GameSession).GetProperty("Settings").SetValue(game, settings);
var fields = BindingFlags.Instance | BindingFlags.NonPublic;
typeof(MechPilot).GetField("game", fields).SetValue(pilot, game);
typeof(MechPilot).GetField("visual", fields).SetValue(pilot, host.transform);
pilot.ApplyLoadout();
Assert.AreEqual(primary.magazineSize, pilot.Rounds);
string primaryUid = store.Data.run.installed.Find(i => i.itemId == "rapid").uid;
string secondaryUid = store.Data.run.installed.Find(i => i.itemId == "guided").uid;
Assert.IsTrue(store.Uninstall(0, Hold.Cargo, 0, 0, false));
Assert.IsTrue(store.Uninstall(1, Hold.SafeCase, 0, 0, true));
pilot.ApplyLoadout();
Assert.IsNull(pilot.Primary);
Assert.AreEqual(0, pilot.Rounds);
Assert.AreEqual(0, pilot.ReloadRemaining);
var field = new VisualElement();
var vitals = new VisualElement();
var instruments = new CombatInstruments(field, vitals, game);
Assert.DoesNotThrow(instruments.Refresh);
Assert.AreEqual(GameLocalization.Text("ui.combat.empty"), field.Q<Label>(className: "combat-primary").text);
Assert.AreEqual(GameLocalization.Text("ui.combat.empty"), field.Q<Label>(className: "combat-secondary").text);
Assert.AreEqual(GameLocalization.Text("ui.combat.cargo", 3, 2), vitals.Q<Label>(className: "combat-cargo").text);
Assert.IsTrue(store.InstallItem(primaryUid, Hold.Cargo, parts => true));
pilot.ApplyLoadout();
instruments.Refresh();
Assert.AreSame(primary, pilot.Primary);
Assert.AreEqual(primary.magazineSize.ToString(), field.Q<Label>(className: "combat-rounds").text);
Assert.IsTrue(store.Finish(true));
Assert.AreEqual(secondaryUid, store.Data.warehouse[0].uid);
Assert.AreEqual(1, store.Data.warehouse.Count);
}
finally
{
GameLocalization.SelectLocale(locale, false);
settings?.Dispose();
UnityEngine.Object.DestroyImmediate(host);
if (input) UnityEngine.Object.DestroyImmediate(input);
if (frame) UnityEngine.Object.DestroyImmediate(frame);
if (Directory.Exists(folder)) Directory.Delete(folder, true);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7b18f28203ef94c4ab853dfc6306ad5e
+1 -1
View File
@@ -1 +1 @@
{"name":"Ironspore.Tests","references":["Ironspore.Runtime"],"includePlatforms":["Editor"],"optionalUnityReferences":["TestAssemblies"],"autoReferenced":false}
{"name":"Ironspore.Tests","references":["Ironspore.Runtime","Unity.InputSystem"],"includePlatforms":["Editor"],"optionalUnityReferences":["TestAssemblies"],"autoReferenced":false}
+3 -2
View File
@@ -67,7 +67,8 @@ namespace Ironspore.Tests
int count = expected.Length == 0 ? 0 : expected.Select(int.Parse).Max() + 1;
Assert.DoesNotThrow(() => string.Format(entry.Value, Enumerable.Repeat<object>("sample", count).ToArray()), entry.Key);
foreach (char character in entry.Value.Where(c => !char.IsControl(c)))
Assert.IsTrue(catalog.uiFont.HasCharacter(character), "Missing glyph: " + character + " in " + entry.Key);
// Dynamic atlases are cleared on build; verify that the source font can supply each glyph.
Assert.IsTrue(catalog.uiFont.HasCharacter(character, false, true), "Missing glyph: " + character + " in " + entry.Key);
}
}
}
@@ -97,7 +98,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()
{
+337
View File
@@ -0,0 +1,337 @@
using System;
using System.IO;
using System.Linq;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.InputSystem;
namespace Ironspore.Tests
{
public sealed class SettingsTests
{
string folder, path;
InputActionAsset input;
SettingsService service;
GameSettingsData defaults;
[SetUp]
public void Setup()
{
folder = Path.Combine(Path.GetTempPath(), "IronsporeSettingsTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(folder);
path = Path.Combine(folder, "settings.json");
input = InputActionAsset.FromJson(File.ReadAllText("Assets/Game/Input/GameInput.inputactions"));
defaults = new GameSettingsData();
}
[TearDown]
public void Cleanup()
{
service?.Dispose();
service = null;
if (input) UnityEngine.Object.DestroyImmediate(input);
string allowed = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "IronsporeSettingsTests")) + Path.DirectorySeparatorChar;
if (Path.GetFullPath(folder).StartsWith(allowed, StringComparison.OrdinalIgnoreCase) && Directory.Exists(folder))
Directory.Delete(folder, true);
}
SettingsService StartService(string file = null, Func<float> clock = null) =>
service = new SettingsService(new SettingsStore(file ?? path, defaults), input, defaults, clock);
[Test]
public void CompleteSnapshotSurvivesReloadAndCallerEdits()
{
var store = new SettingsStore(path);
var candidate = new GameSettingsData { masterVolume = .35f, mouseSensitivity = 2.5f, aimSensitivity = .6f,
qualityLevel = 4, resolutionWidth = 1920, resolutionHeight = 1080, fullscreen = true, bindingOverrides = "stable input IDs" };
Assert.IsTrue(store.Save(candidate));
candidate.masterVolume = 0;
var loaded = new SettingsStore(path).Data;
Assert.AreEqual(.35f, loaded.masterVolume);
Assert.AreEqual(.35f, store.Data.masterVolume);
Assert.AreEqual(2.5f, loaded.mouseSensitivity);
Assert.AreEqual(.6f, loaded.aimSensitivity);
Assert.AreEqual(4, loaded.qualityLevel);
Assert.AreEqual(1920, loaded.resolutionWidth);
Assert.AreEqual(1080, loaded.resolutionHeight);
Assert.IsTrue(loaded.fullscreen);
Assert.AreEqual("stable input IDs", loaded.bindingOverrides);
}
[Test]
public void MissingFieldsRetainDefaults()
{
File.WriteAllText(path, "{\"version\":1,\"masterVolume\":0.4}");
var loaded = new SettingsStore(path, defaults).Data;
Assert.AreEqual(.4f, loaded.masterVolume);
Assert.AreEqual(1, loaded.mouseSensitivity);
Assert.AreEqual(720, loaded.resolutionHeight);
}
[Test]
public void CorruptPrimaryRecoversLastCompleteBackup()
{
var store = new SettingsStore(path);
Assert.IsTrue(store.Save(new GameSettingsData { masterVolume = .4f }));
Assert.IsTrue(store.Save(new GameSettingsData { masterVolume = .7f }));
File.WriteAllText(path, "{broken json");
Assert.AreEqual(.4f, new SettingsStore(path).Data.masterVolume);
}
[Test]
public void WriteFailureAndUnsupportedVersionPreservePreviousSnapshot()
{
string blocker = Path.Combine(folder, "blocked");
File.WriteAllText(blocker, "not a directory");
var store = new SettingsStore(Path.Combine(blocker, "settings.json"));
Assert.IsFalse(store.Save(new GameSettingsData { masterVolume = .25f }));
Assert.AreEqual(1, store.Data.masterVolume);
Assert.IsNotEmpty(store.Error);
Assert.IsFalse(store.Save(new GameSettingsData { version = 99 }));
Assert.AreEqual(1, store.Data.version);
}
[Test]
public void InvalidNumbersAreNormalizedWithoutMutatingSource()
{
var data = new GameSettingsData { masterVolume = float.NaN, mouseSensitivity = float.PositiveInfinity,
aimSensitivity = -5, qualityLevel = 99, resolutionWidth = -1, resolutionHeight = 0 };
var normalized = data.Normalize(6);
Assert.AreEqual(1, normalized.masterVolume);
Assert.AreEqual(1, normalized.mouseSensitivity);
Assert.AreEqual(.1f, normalized.aimSensitivity);
Assert.AreEqual(5, normalized.qualityLevel);
Assert.AreEqual(1280, normalized.resolutionWidth);
Assert.AreEqual(-1, data.resolutionWidth);
}
[Test]
public void MovementPartsAndAllButtonsCanBeReboundWithoutChangingTemplateIds()
{
var bindings = new InputBindingService(input);
Assert.AreEqual(16, bindings.Slots.Count);
Assert.AreEqual(4, bindings.Slots.Count(x => x.Action.name == "Move"));
Assert.IsFalse(bindings.Slots.Any(x => x.Action.name == "Look" || x.Action.bindings[x.Index].isComposite));
var target = bindings.Slots.First(x => x.Action.name == "Interact");
Guid original = target.Action.bindings[target.Index].id;
Assert.AreEqual(BindingChange.Success, bindings.Rebind(target, "<Keyboard>/j", out _));
string json = bindings.Save();
bindings.Reset();
Assert.AreEqual("<Keyboard>/e", target.Action.bindings[target.Index].effectivePath);
Assert.IsTrue(bindings.TryLoad(json));
Assert.AreEqual("<Keyboard>/j", target.Action.bindings[target.Index].effectivePath);
Assert.AreEqual(original, target.Action.bindings[target.Index].id);
}
[Test]
public void OlderInteractionOnRSurvivesAddingReload()
{
var bindings = new InputBindingService(input);
var interact = input.FindAction("Pilot/Interact", true);
interact.ApplyBindingOverride(0, "<Keyboard>/r");
string legacy = bindings.Save();
bindings.Reset();
Assert.IsTrue(bindings.TryLoad(legacy));
Assert.AreEqual("<Keyboard>/r", interact.bindings[0].effectivePath);
Assert.AreEqual("<Keyboard>/t", input.FindAction("Pilot/Reload", true).bindings[0].effectivePath);
}
[Test]
public void ConflictsReservedKeysAndMouseMotionDoNotReplaceBinding()
{
var bindings = new InputBindingService(input);
var target = bindings.Slots.First(x => x.Action.name == "Interact");
Assert.AreEqual(BindingChange.Conflict, bindings.Rebind(target, "<Keyboard>/w", out var conflict));
Assert.AreEqual("Move", conflict.Action.name);
Assert.AreEqual(BindingChange.Reserved, bindings.Rebind(target, "<Keyboard>/escape", out _));
Assert.AreEqual(BindingChange.Unsupported, bindings.Rebind(target, "<Mouse>/delta", out _));
Assert.AreEqual(BindingChange.Unsupported, bindings.Rebind(target, "<Gamepad>/buttonSouth", out _));
Assert.AreEqual("<Keyboard>/e", target.Action.bindings[target.Index].effectivePath);
}
[Test]
public void InvalidOverridesDoNotDestroyExistingBindings()
{
var bindings = new InputBindingService(input);
var target = bindings.Slots.First(x => x.Action.name == "Interact");
bindings.Rebind(target, "<Mouse>/middleButton", out _);
Assert.IsFalse(bindings.TryLoad("{broken json"));
Assert.AreEqual("<Mouse>/middleButton", target.Action.bindings[target.Index].effectivePath);
}
[Test]
public void DigitZeroAndPunctuationUsePhysicalKeyboardLayoutNames()
{
var bindings = new InputBindingService(input);
var target = bindings.Slots.First(x => x.Action.name == "Interact");
Assert.AreEqual(BindingChange.Success, bindings.Rebind(target, "<Keyboard>/0", out _));
Assert.AreEqual(BindingChange.Success, bindings.Rebind(target, "<Keyboard>/semicolon", out _));
Assert.AreEqual(BindingChange.Unsupported, bindings.Rebind(target, "<Keyboard>/999", out _));
Assert.AreEqual("<Keyboard>/semicolon", target.Action.bindings[target.Index].effectivePath);
}
[Test]
public void SavedOverridesCannotTurnMouseLookIntoAButton()
{
var bindings = new InputBindingService(input);
var look = input.FindAction("Pilot/Look", true);
look.ApplyBindingOverride(0, "<Mouse>/leftButton");
string invalid = bindings.Save();
bindings.Reset();
Assert.IsFalse(bindings.TryLoad(invalid));
Assert.AreEqual("<Mouse>/delta", look.bindings[0].effectivePath);
}
[Test]
public void CancellingInteractiveCaptureRestoresEnabledActions()
{
var bindings = new InputBindingService(input);
input.Enable();
var target = bindings.Slots.First(x => x.Action.name == "Interact");
BindingChange? result = null;
bindings.Begin(target, (change, _) => result = change);
Assert.IsTrue(bindings.IsRebinding);
Assert.IsFalse(target.Action.enabled);
bindings.Cancel();
Assert.IsFalse(bindings.IsRebinding);
Assert.AreEqual(BindingChange.Cancelled, result);
Assert.IsTrue(input.All(x => x.enabled));
Assert.AreEqual("<Keyboard>/e", target.Action.bindings[target.Index].effectivePath);
}
[TestCase(Key.J, BindingChange.Success)]
[TestCase(Key.W, BindingChange.Conflict)]
[TestCase(Key.Escape, BindingChange.Cancelled)]
public void NativeKeyboardCaptureCompletesAndSuppressesItsMenuInput(Key key, BindingChange expected)
{
var previous = Keyboard.current;
var keyboard = InputSystem.AddDevice<Keyboard>("SettingsCaptureTest");
var bindings = new InputBindingService(input);
try
{
input.Enable();
var target = bindings.Slots.First(x => x.Action.name == "Interact");
BindingChange? result = null;
bindings.Begin(target, (change, _) => result = change);
InputSystem.QueueStateEvent(keyboard, new UnityEngine.InputSystem.LowLevel.KeyboardState(key));
InputSystem.Update();
Assert.AreEqual(expected, result);
Assert.IsFalse(bindings.IsRebinding);
Assert.IsTrue(bindings.SuppressMenuInput);
Assert.IsTrue(target.Action.enabled);
Assert.AreEqual(key == Key.J ? "<Keyboard>/j" : "<Keyboard>/e", target.Action.bindings[target.Index].effectivePath);
}
finally
{
bindings.Dispose();
InputSystem.RemoveDevice(keyboard);
previous?.MakeCurrent();
}
}
[Test]
public void ApplySavesVolumeSensitivityAndBindingsTogether()
{
StartService();
var draft = service.Current;
draft.masterVolume = .25f;
draft.mouseSensitivity = 2;
var target = service.Bindings.Slots.First(x => x.Action.name == "Interact");
service.Bindings.Rebind(target, "<Keyboard>/j", out _);
Assert.AreEqual(SettingsApplyResult.Saved, service.Apply(draft));
Assert.AreEqual(.25f, AudioListener.volume);
Assert.AreEqual(2, service.LookSensitivity);
var reloaded = new SettingsStore(path).Data;
Assert.AreEqual(.25f, reloaded.masterVolume);
Assert.AreEqual(2, reloaded.mouseSensitivity);
StringAssert.Contains("<Keyboard>/j", reloaded.bindingOverrides);
}
[TestCase(KeyCode.J, BindingChange.Success)]
[TestCase(KeyCode.R, BindingChange.Conflict)]
[TestCase(KeyCode.W, BindingChange.Conflict)]
[TestCase(KeyCode.Alpha0, BindingChange.Success)]
public void UiKeyboardCaptureUsesTheSameValidationAndCompletesOnce(KeyCode key, BindingChange expected)
{
var bindings = new InputBindingService(input);
input.Enable();
var target = bindings.Slots.First(x => x.Action.name == "Interact");
BindingChange? result = null;
int completions = 0;
bindings.Begin(target, (change, _) => { result = change; completions++; });
Assert.IsTrue(bindings.CapturePath(SettingsInputCapture.KeyboardPath(key)));
Assert.AreEqual(expected, result);
Assert.AreEqual(1, completions);
Assert.IsFalse(bindings.IsRebinding);
Assert.IsTrue(target.Action.enabled);
Assert.IsTrue(bindings.SuppressMenuInput);
Assert.IsFalse(bindings.CapturePath("<Keyboard>/j"));
}
[Test]
public void DisplayPreviewExpiresDuringPauseWithoutWritingCandidate()
{
float time = 10;
StartService(clock: () => time);
var draft = service.Current;
draft.resolutionWidth = 1920;
draft.resolutionHeight = 1080;
draft.masterVolume = .2f;
Assert.AreEqual(SettingsApplyResult.ConfirmDisplay, service.Apply(draft));
Assert.IsFalse(File.Exists(path));
Assert.AreEqual(15, service.ConfirmationSeconds);
Assert.AreEqual(.2f, AudioListener.volume);
time += 16;
Assert.IsTrue(service.Tick());
Assert.IsFalse(service.ConfirmingDisplay);
Assert.AreEqual(1280, service.Current.resolutionWidth);
Assert.AreEqual(1, AudioListener.volume);
Assert.IsFalse(File.Exists(path));
}
[Test]
public void DisplayConfirmationPersistsTheEntireSnapshot()
{
StartService();
var draft = service.Current;
draft.resolutionWidth = 1920;
draft.resolutionHeight = 1080;
draft.masterVolume = .6f;
Assert.AreEqual(SettingsApplyResult.ConfirmDisplay, service.Apply(draft));
Assert.IsTrue(service.ConfirmDisplay());
var saved = new SettingsStore(path).Data;
Assert.AreEqual(1920, saved.resolutionWidth);
Assert.AreEqual(.6f, saved.masterVolume);
}
[Test]
public void FailedApplyDoesNotChangeCurrentOrRuntimeValues()
{
string blocker = Path.Combine(folder, "blocked");
File.WriteAllText(blocker, "not a directory");
StartService(Path.Combine(blocker, "settings.json"));
var draft = service.Current;
draft.masterVolume = .2f;
draft.mouseSensitivity = 4;
Assert.AreEqual(SettingsApplyResult.Failed, service.Apply(draft));
Assert.AreEqual(1, service.Current.masterVolume);
Assert.AreEqual(1, service.LookSensitivity);
Assert.AreEqual(1, AudioListener.volume);
Assert.IsNotEmpty(service.Error);
}
[Test]
public void DiscardRestoresSavedBindingsAndProtectsCurrentFromDraftMutation()
{
StartService();
var draft = service.Current;
draft.masterVolume = 0;
var target = service.Bindings.Slots.First(x => x.Action.name == "Interact");
service.Bindings.Rebind(target, "<Keyboard>/j", out _);
service.DiscardDraft();
Assert.AreEqual("<Keyboard>/e", target.Action.bindings[target.Index].effectivePath);
Assert.AreEqual(1, service.Current.masterVolume);
Assert.IsFalse(File.Exists(path));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e6046d9efdd879546aa0718168ddf78e
+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)。
+40
View File
@@ -0,0 +1,40 @@
# 游戏设置
主页和暂停菜单共用设置界面,提供总音量、画质、分辨率、窗口/全屏、鼠标灵敏度、精瞄倍率以及键鼠改绑。界面沿用现有 UI Toolkit 和机库配色,文本使用现有本地化目录中的稳定键。
## 职责与依赖
| 类型 | 职责 |
| --- | --- |
| `GameSettingsData` | 独立的设置快照、复制与数值校验;不引用 UI 或游戏进度。 |
| `SettingsStore` | 读取独立的 `settings.json`,临时文件写入后原子替换,保留 `.bak`;写入失败时不替换内存中的已保存快照。 |
| `SettingsService` | 应用声音、画质、显示与灵敏度设置;管理显示预览、确认和恢复。时钟可注入,暂停时按真实时间计时。 |
| `InputBindingService` | 对克隆后的 Input Actions 应用原生 binding overrides;处理采集、冲突校验、取消与释放,不修改输入模板及 GUID。 |
| `SettingsScreen` | 构建设置界面,持有编辑草稿、分页、焦点、状态消息和输入采集弹层;通过服务应用与保存。 |
| `GameHud` / `GameSession` | 负责设置入口、返回原菜单、输入交接与生命周期。`MechPilot` 读取服务中的灵敏度。 |
调用关系为 `GameSession → SettingsService → SettingsStore / InputBindingService`,`GameHud → SettingsScreen → SettingsService`。不引入全局事件总线、服务定位器或额外框架。
## 修改与返回
- 打开界面时复制已保存快照。切换分页和语言保留当前草稿。语言沿用 `GameLocalization` 的即时切换与独立偏好保存。
- 点击应用后保存完整设置。保存失败时显示可重试消息,不将失败状态当作已保存状态。
- 分辨率或显示模式变化先预览,15 秒内确认后保存;超时或取消恢复之前的显示、音量与画质。计时不受暂停影响。
- 返回时放弃尚未应用的修改,并恢复已保存的按键。恢复默认也先进入草稿,需点击应用保存。
- 从暂停菜单进入设置后保持暂停,返回时仍停留在暂停菜单。处理设置输入的帧不会继续触发驾驶、货仓或关闭暂停。
## 按键
16 项可改绑输入包含四个移动方向、主武器、精瞄、推进、跳跃/升空、视角、交互、货仓、回收舱、暂停、副武装、主动防护和换弹。货仓内使用 R 旋转选中的物品,驾驶时换弹读取当前绑定。镜头移动固定使用鼠标位移。
使用 Input System 自带的交互式改绑与 JSON overrides,并用绑定 ID 保持模板兼容。`SettingsInputCapture` 将 UI Toolkit 的键盘和指针事件适配到同一改绑服务,兼容辅助输入和未转发为原始设备事件的输入;两条路径共用冲突检查,且只完成一次。接受实际键盘按键及五个鼠标按钮;重复键、鼠标位移和不支持的设备不覆盖原绑定。Esc 取消采集并保留为菜单返回键。采集期间禁用驾驶动作,完成、取消、失焦和关闭时恢复原有启用状态并释放采集对象。完成采集后同时抑制当前帧和下一帧的菜单输入,避免被采集的按键继续关闭界面。
机库提示、HUD、货舱说明、试验场通知和世界交互提示从当前绑定生成,切换语言后一起刷新。
## 持久化与开发隔离
`settings.json` 使用独立的 `version = 1`,装备进度分别保存在 `profile-v2.json`、`range-v2.json`,两者通过同一数据目录入口隔离。数值载入时检查范围,损坏的主文件尝试从备份恢复。设置文件中的按键覆盖也会校验,避免将镜头向量输入替换成按钮输入。
普通 Windows 玩家沿用原先 `Application.persistentDataPath/Ironspore/` 路径及 `settings.locale` 语言偏好键。Unity 编辑器使用各工程自身的 `UserSettings/Ironspore/`,并按完整路径隔离语言偏好。独立程序测试可传入 `-ironspore-data-path <绝对路径>`,该目录中的设置、进度与语言偏好均独立于正式数据。
界面验证与构建记录归档在 `Docs/QA/Settings/`。设置和改绑测试位于 `Assets/Tests/EditMode/SettingsTests.cs`,与原有存档和本地化测试一起执行。
@@ -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,`tree3` 已接入地图改善分支 `codex/map-improvement` 的独立 worktree。真实灯光、遮挡和动态阴影按 [地图光影优化计划](LightingOptimizationPlan.md) 实施。光照、独立动态阴影及功能回归通过;本机原生 1080p 中等档正式六组无限帧采样 P95 为 5.67–6.31 ms,三个候选画质档均通过。默认 60 FPS 限帧实测平均 60.01 FPS,但 P95 17.23–17.28 ms 未通过严格 16.7 ms 判据;烘焙、精确阴影增量和其他未验证范围见 [光影验收记录](../QA/Lighting/Acceptance.md)。
@@ -91,23 +93,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 · 完整试玩版
@@ -125,24 +127,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 |
+9
View File
@@ -0,0 +1,9 @@
# 战斗 HUD 验收
Unity 6000.6.8f1,分支 `codex/ui-improvements`。
- 两张 1920 × 1080 游戏视图截图均来自同一次试验场运行。玩家位置为 (0, 0.05, −18),朝向 0°。发射七发后固定模拟时间,通过 View 输入切换视角,弹匣均剩 29 发。[第一人称](Screenshots/first-person.png) · [第三人称](Screenshots/third-person.png)
- 实测速射炮按 R 从 29 发提前换弹并补满 36 发。持续开火至 0 发后自动进入 2 秒换弹,再次补满。局内安装电磁步枪后,英文 HUD 更新为 Lance / coil rifle,弹匣 12 发。
- 完成中继站目标后,导航目标由 NavigationRelay 更新为 OnwardGate。接应倒计时结束后,HUD 显示可登机。
- Windows x64 构建任务 `build-b26b71acc5` 成功,0 错误、0 警告。独立程序在隔离数据目录启动,以 1280 × 720 从机库进入实际出击,核对了中文 HUD 和小地图。
- EditMode 任务 `c29414af9e7d498b8fc1de7284621792` 通过 43/43 项,包含旧配置中 R 已被占用的迁移测试。本轮未采样帧率。
Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

+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

+53
View File
@@ -0,0 +1,53 @@
{
"tests": {
"result": {
"success": true,
"message": "Test job status retrieved.",
"error": null,
"data": {
"job_id": "f75ccb152ffd41ec82ff0e9ec0c0881c",
"status": "succeeded",
"mode": "EditMode",
"started_unix_ms": 1794478624703,
"finished_unix_ms": 1794478630319,
"last_update_unix_ms": 1794478630319,
"progress": {
"completed": 50,
"total": 50,
"current_test_full_name": null,
"current_test_started_unix_ms": null,
"last_finished_test_full_name": "Ironspore.Tests.SettingsTests.WriteFailureAndUnsupportedVersionPreservePreviousSnapshot",
"last_finished_unix_ms": 1794478630114,
"stuck_suspected": false,
"editor_is_focused": true,
"run_in_background": false,
"focus_nudge_attempts": null,
"focus_nudge_limit": null,
"focus_nudge_status": null,
"blocked_reason": null,
"failures_so_far": [],
"failures_capped": false
},
"error": null,
"result": null
},
"hint": null
}
},
"build": {
"success": true,
"message": "Build Succeeded.",
"data": {
"job_id": "build-0b8f03124f",
"result": "succeeded",
"platform": "StandaloneWindows64",
"output_path": "D:/vibeCoding/unityRouge/workTree/tree1/Builds/Windows/x64/Integration/Ironspore.exe",
"started_at": "2026-11-12T10:20:11.9923316Z",
"duration_seconds": 9.665725499999999,
"completed_at": "2026-11-12T10:20:21.6580571Z",
"total_size_mb": 111.33,
"errors": 0,
"warnings": 0
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+36
View File
@@ -0,0 +1,36 @@
# tree2 合入 tree1 验收
2026-11-12,Unity 6000.6.8f1。
目标为 tree1 的 `codex/models-weapons-equipment-animation`,合并前提交 `e1d38c5`。来源为 tree2 的 `codex/ui-improvements`,提交 `97fbf18`。共同基线为 `0857e29`。本次以保留双方历史的合并提交交付。
## 合并处理
- 解决中英文文本表、GameLocalization、MechPilot、GameSession、GameHud 和 USS 共七个文件的文本冲突。
- 合入设置服务、改绑、弹匣换弹、小地图和战斗 HUD,并保留二维货仓、装备实例、安全箱、空槽及机毁回收流程。
- 统一通过 GameDataPaths 定位设置、语言偏好和进度,正式档与试验场档使用 profile-v2.json、range-v2.json。
- HUD 按物品占格显示货仓和安全箱容量。空主武器显示空槽并清空弹量、取消换弹,空副武装显示未安装。
- 机毁处理优先于设置菜单的输入屏蔽。菜单清理同时释放物品拖拽和设置页状态。
## 自动化测试
Unity MCP EditMode 作业 `f75ccb152ffd41ec82ff0e9ec0c0881c`,**50/50 通过**。完整摘要见[测试与构建结果](Results.json)。
新增 EquipmentHudTests 覆盖真实 ProfileStore 卸装、MechPilot 属性刷新、CombatInstruments 空槽与实际占格显示、重新安装后的弹量,以及被卸下副武器的机毁保全。
## 编辑器操作
- 绑定 `tree1@312844bd` 并核实完整工程路径,编译后 Console 无错误或警告。
- 注入 Input System 鼠标状态开火后,速射炮从 36 发降至 11 发;注入 R 后补满。
- Tab 打开二维货仓,通过界面选中副武器,再注入 R 旋转并落入安全箱第二行。槽位变空,HUD 显示安全箱 2/4 格,主武器保持 36 发且未启动换弹。[中文 1920×1080](Screenshots/equipment-zh-1920x1080.png)
- 从暂停进入设置,将音量设为 42%,通过实际改绑入口把主动货舱改为 P,并应用保存。切换英文、返回暂停后,出击快照保持一致,暂停时间倍率仍为 0。货仓说明显示新按键。[设置页](Screenshots/settings-en-1920x1080.png) · [英文 1280×720](Screenshots/equipment-en-1280x720.png)
- 设置页保持打开时施加致命伤害,自动返回机库并恢复时间倍率 1。重载试验场存档,同一副武器 UID 仅返库一件,弹射和返航收据各一条,正式机库仍为四件装备。
- 结束后恢复原来的未命名场景和编辑器测试数据快照。
## Windows 独立程序
Unity MCP 构建作业 `build-0b8f03124f` 成功,**0 错误、0 警告**。产物为 `Builds/Windows/x64/Integration/Ironspore.exe`,须保留完整目录。
以 `-ironspore-data-path Logs/Integration/Standalone/PlayerData` 对应的绝对路径启动,独立测试数据与正式数据分开。使用 computer-use 技能在 1280×720 窗口中完成试验场进入、HUD 与小地图检查、失焦暂停、设置页中英文切换、Esc 返回暂停、试验场返回机库和关闭程序。[独立版暂停界面](Screenshots/windows-pause-en-1280x720.png)
本次自动注入的 Tab 和 Esc 驾驶动作未被独立程序的 Input System 接收,设置页的键盘适配能接收 Esc。驾驶按键、旋转和换弹联动在编辑器内通过 Input System 验证。独立程序的货仓按键操作、完整出击和性能采样仍需实机补验。
@@ -0,0 +1,261 @@
{
"tests": {
"result": {
"success": true,
"message": "Test job status retrieved.",
"error": null,
"data": {
"job_id": "6ef92191d6af4d66ba3dfff6de928de2",
"status": "succeeded",
"mode": "EditMode",
"started_unix_ms": 1794480510700,
"finished_unix_ms": 1794480516349,
"last_update_unix_ms": 1794480516349,
"progress": {
"completed": 50,
"total": 50,
"current_test_full_name": null,
"current_test_started_unix_ms": null,
"last_finished_test_full_name": "Ironspore.Tests.SettingsTests.WriteFailureAndUnsupportedVersionPreservePreviousSnapshot",
"last_finished_unix_ms": 1794480516145,
"stuck_suspected": false,
"editor_is_focused": false,
"run_in_background": true,
"focus_nudge_attempts": null,
"focus_nudge_limit": null,
"focus_nudge_status": null,
"blocked_reason": null,
"failures_so_far": [],
"failures_capped": false
},
"error": null,
"result": {
"mode": "EditMode",
"summary": {
"total": 50,
"passed": 50,
"failed": 0,
"skipped": 0,
"durationSeconds": 1.6837374,
"resultState": "Passed"
},
"results": []
}
},
"hint": null
}
},
"build": {
"success": true,
"message": "Build Succeeded.",
"data": {
"job_id": "build-1a262a9861",
"result": "succeeded",
"platform": "StandaloneWindows64",
"output_path": "D:/vibeCoding/unityRouge/workTree/tree1/Builds/Windows/x64/Integration/Ironspore.exe",
"started_at": "2026-11-12T10:39:32.0228615Z",
"duration_seconds": 12.9526607,
"completed_at": "2026-11-12T10:39:44.9755222Z",
"total_size_mb": 177,
"errors": 0,
"warnings": 0
}
},
"scenes": {
"success": true,
"message": "Code executed successfully.",
"data": {
"result": [
{
"name": "Bootstrap",
"lighting": "Assets/Game/Data/Lighting/Daylight.asset",
"dirty": false
},
{
"name": "MechTestRange",
"lighting": "Assets/Game/Data/Lighting/Daylight.asset",
"dirty": false
},
{
"name": "WarzonePrototype",
"lighting": "Assets/Game/Data/Lighting/Daylight.asset",
"dirty": false
}
],
"compiler": "codedom"
}
},
"target": {
"success": true,
"message": "Code executed successfully.",
"data": {
"result": {
"hits": 1,
"duplicate": false,
"rounds": 0,
"firstPersonCasters": 26,
"frame": 976
},
"compiler": "codedom"
}
},
"crates": {
"success": true,
"message": "Code executed successfully.",
"data": {
"result": {
"movingFirstHit": true,
"movingDuplicate": false,
"blockedWhenFull": true,
"searched": 2,
"resetPreservesSearch": true,
"uninstalledPart": true,
"cargo": 3
},
"compiler": "codedom"
}
},
"crateAnimation": {
"success": true,
"message": "Code executed successfully.",
"data": {
"result": {
"openedAngle": 88.00002,
"closedAngle": 0,
"searched": 2
},
"compiler": "codedom"
}
},
"lowQuality": {
"success": true,
"message": "Code executed successfully.",
"data": {
"result": {
"quality": 0,
"sun": "Hard",
"sunResolution": 1024,
"enabledSpots": 0,
"probe": 128
},
"compiler": "codedom"
}
},
"highQuality": {
"success": true,
"message": "Code executed successfully.",
"data": {
"result": {
"quality": 5,
"sun": "Soft",
"sunResolution": 4096,
"enabledSpots": 2,
"probe": 256
},
"compiler": "codedom"
}
},
"bindings": {
"success": true,
"message": "Code executed successfully.",
"data": {
"result": {
"change": "Success",
"prompt": "重置练习 [J]",
"instruction": "按 [J] 搜索箱子,按 [Tab] 选择货物,原地按住 [G] 发射。"
},
"compiler": "codedom"
}
},
"podAndCase": {
"success": true,
"message": "Code executed successfully.",
"data": {
"result": {
"moved": true,
"sent": true,
"safeUid": "9c0d7375c899406990940a54b36165a7",
"safeArea": 4,
"pod": true,
"searched": 2
},
"compiler": "codedom"
}
},
"settlement": {
"success": true,
"message": "Code executed successfully.",
"data": {
"result": {
"hangar": true,
"caseCopies": 1,
"activeReturn": 1,
"caseReturn": 1,
"finishReturn": 1,
"mainStock": 4
},
"compiler": "codedom"
}
},
"lifecycle": [
{
"success": true,
"message": "Code executed successfully.",
"data": {
"result": {
"chassis": 0,
"worlds": 1,
"suns": 1,
"probes": 1,
"cameras": 1,
"casters": 26,
"training": true,
"reflection": false
},
"compiler": "codedom"
}
},
{
"success": true,
"message": "Code executed successfully.",
"data": {
"result": {
"chassis": 2,
"worlds": 1,
"suns": 1,
"probes": 1,
"cameras": 1,
"casters": 26,
"training": true,
"reflection": false
},
"compiler": "codedom"
}
}
],
"physicsLayers": {
"success": true,
"message": "Code executed successfully.",
"data": {
"result": [
{
"layer": 0,
"blocks": true
},
{
"layer": 10,
"blocks": true
},
{
"layer": 8,
"blocks": false
},
{
"layer": 9,
"blocks": false
}
],
"compiler": "codedom"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,39 @@
{
"recordedUtc": "2026-11-12T10:45:35.9183339+00:00",
"project": "D:\\vibeCoding\\unityRouge\\workTree\\tree1",
"cpu": [
{
"Name": "AMD Ryzen 9 5900HX with Radeon Graphics ",
"NumberOfCores": 8,
"NumberOfLogicalProcessors": 16
}
],
"ramGiB": 31.42,
"gpu": [
{
"Name": "AMD Radeon(TM) Graphics",
"DriverVersion": "30.0.13002.19003"
},
{
"Name": "GameViewer Virtual Display Adapter",
"DriverVersion": "15.6.5.199"
},
{
"Name": "NVIDIA GeForce RTX 3070 Laptop GPU",
"DriverVersion": "32.0.15.8180"
}
],
"battery": [
{
"BatteryStatus": 2,
"EstimatedChargeRemaining": 100
}
],
"powerPlan": "电源方案 GUID: 381b4222-f694-41f0-9685-ff5bb260df2e (平衡)",
"runtimeAssemblySha256": "0B79D979319AE7BF5A1A8E2D422361D89B40920ADC24E8A0548D7A8B4C6C9146",
"profileSha256": "062B06729EFF52AD3CF27367953B869C0D774A27F0B5DCF10E5D9D515BF0ECB0",
"competingPlayers": [],
"nvidia": [
"NVIDIA GeForce RTX 3070 Laptop GPU, 581.80, 8192 MiB, 17.65 W, 72, 210 MHz"
]
}
@@ -0,0 +1,82 @@
{
"tag": "Medium",
"startedUtc": "2026-11-12T10:45:41.5325428Z",
"finishedUtc": "2026-11-12T10:48:03.6009417Z",
"gpu": "NVIDIA GeForce RTX 3070 Laptop GPU",
"api": "Direct3D12",
"cpu": "AMD Ryzen 9 5900HX with Radeon Graphics ",
"colorSpace": "Linear",
"quality": "Medium",
"persistentDataPath": "C:/Users/15089/AppData/LocalLow/UnityRouge/Ironspore Prototype",
"dataDirectory": "D:\\vibeCoding\\unityRouge\\workTree\\tree1\\Docs\\QA\\Integration\\Tree3\\Performance-Capped60\\PlayerData",
"routeVersion": "perimeter-and-center-v2",
"width": 1920,
"height": 1080,
"gpuMemoryMiB": 8020,
"systemMemoryMiB": 32174,
"cascades": 2,
"vSync": 0,
"frameCap": 60,
"firstPersonShadowOnlyRenderers": 26,
"movingShadowPixels": 95868,
"sunShadowPixels": 64025,
"localLitPixels": 86340,
"localBlockedPixels": 83228,
"sunResolution": 2048,
"cameraWidth": 1920,
"cameraHeight": 1080,
"branch": 0,
"shadowDistance": 60.0,
"renderWidthScale": 1.0,
"renderHeightScale": 1.0,
"warmupSeconds": 10.0,
"sampleSeconds": 60.0,
"completed": true,
"frameTimingEnabled": true,
"localLightCastsShadows": true,
"visualPassed": true,
"dynamicResolution": false,
"routeCompleted": false,
"segments": [
{
"name": "shoulder-1",
"frames": 3600,
"width": 1920,
"height": 1080,
"gpuSamples": 3600,
"cpuSamples": 3600,
"enemyCount": 8,
"waypointsVisited": 5,
"cameraRenderFrames": 3600,
"meanFps": 60.013248324655926,
"p50Ms": 16.666499999999473,
"p95Ms": 16.982499999997458,
"p99Ms": 17.13310000000945,
"gpuP95Ms": 3.810304,
"cpuP95Ms": 16.6846,
"allocatedMiB": 158.759859085083,
"distanceTravelled": 210.01136016845703,
"passed": false
},
{
"name": "cockpit-1",
"frames": 3600,
"width": 1920,
"height": 1080,
"gpuSamples": 3600,
"cpuSamples": 3600,
"enemyCount": 8,
"waypointsVisited": 5,
"cameraRenderFrames": 3600,
"meanFps": 60.014219569137275,
"p50Ms": 16.667900000001623,
"p95Ms": 16.980200000006107,
"p99Ms": 17.140099999991776,
"gpuP95Ms": 3.5584,
"cpuP95Ms": 16.6826,
"allocatedMiB": 158.64015674591064,
"distanceTravelled": 210.00698852539063,
"passed": false
}
]
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,39 @@
{
"recordedUtc": "2026-11-12T10:40:04.2058227+00:00",
"project": "D:\\vibeCoding\\unityRouge\\workTree\\tree1",
"cpu": [
{
"Name": "AMD Ryzen 9 5900HX with Radeon Graphics ",
"NumberOfCores": 8,
"NumberOfLogicalProcessors": 16
}
],
"ramGiB": 31.42,
"gpu": [
{
"Name": "AMD Radeon(TM) Graphics",
"DriverVersion": "30.0.13002.19003"
},
{
"Name": "GameViewer Virtual Display Adapter",
"DriverVersion": "15.6.5.199"
},
{
"Name": "NVIDIA GeForce RTX 3070 Laptop GPU",
"DriverVersion": "32.0.15.8180"
}
],
"battery": [
{
"BatteryStatus": 2,
"EstimatedChargeRemaining": 100
}
],
"powerPlan": "电源方案 GUID: 381b4222-f694-41f0-9685-ff5bb260df2e (平衡)",
"runtimeAssemblySha256": "0B79D979319AE7BF5A1A8E2D422361D89B40920ADC24E8A0548D7A8B4C6C9146",
"profileSha256": "062B06729EFF52AD3CF27367953B869C0D774A27F0B5DCF10E5D9D515BF0ECB0",
"competingPlayers": [],
"nvidia": [
"NVIDIA GeForce RTX 3070 Laptop GPU, 581.80, 8192 MiB, 15.02 W, 55, 210 MHz"
]
}

Some files were not shown because too many files have changed in this diff Show More