Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f06113339 | ||
|
|
cb972960dc | ||
|
|
eb819c6688 | ||
|
|
e1d38c5a6d |
@@ -33,3 +33,4 @@ __pycache__/
|
||||
/agents.md.txt
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
/Docs/QA/**/PlayerData/
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Profiling;
|
||||
using UnityEngine.Rendering;
|
||||
|
||||
namespace Ironspore.Editor
|
||||
{
|
||||
public static class LightingSmoke
|
||||
{
|
||||
static int step, initialChassis;
|
||||
static double nextAt;
|
||||
static Result result;
|
||||
const string Output = "Docs/QA/Lighting/TestResults/WorldCycles.json";
|
||||
|
||||
public static string Begin()
|
||||
{
|
||||
if (!EditorApplication.isPlaying || !GameSession.Active || result != null)
|
||||
throw new InvalidOperationException("A playing, idle GameSession is required.");
|
||||
initialChassis = GameSession.Active.SelectedChassis;
|
||||
if (GameSession.Active.InField) GameSession.Active.EndSortie(true);
|
||||
result = new Result();
|
||||
step = 0;
|
||||
nextAt = EditorApplication.timeSinceStartup + 1;
|
||||
EditorApplication.update += Tick;
|
||||
return "Three chassis and three field/hangar cycles scheduled; report in about 14 seconds.";
|
||||
}
|
||||
|
||||
static void Tick()
|
||||
{
|
||||
if (EditorApplication.timeSinceStartup < nextAt) return;
|
||||
nextAt = EditorApplication.timeSinceStartup + 1;
|
||||
try
|
||||
{
|
||||
var game = GameSession.Active;
|
||||
if (step >= 12)
|
||||
{
|
||||
result.passed = result.rows.TrueForAll(row => row.passed);
|
||||
Finish();
|
||||
return;
|
||||
}
|
||||
int phase = step % 4;
|
||||
if (phase == 0) { game.SelectedChassis = step / 4; game.StartTrial(); }
|
||||
if (phase == 1)
|
||||
{
|
||||
Snapshot("field", false);
|
||||
typeof(MechPilot).GetProperty("FirstPerson").SetValue(game.Pilot, true);
|
||||
}
|
||||
if (phase == 2) { Snapshot("first-person", true); game.EndSortie(true); }
|
||||
if (phase == 3) Snapshot("hangar", false);
|
||||
step++;
|
||||
}
|
||||
catch (Exception error) { result.error = error.ToString(); Finish(); }
|
||||
}
|
||||
|
||||
static void Snapshot(string phase, bool requireShadowOnly)
|
||||
{
|
||||
var world = GameObject.Find("World");
|
||||
int roots = 0, suns = 0, spots = 0, shadowOnly = 0;
|
||||
foreach (var candidate in UnityEngine.Object.FindObjectsByType<WorldLighting>())
|
||||
if (candidate.gameObject.activeInHierarchy) roots++;
|
||||
foreach (var light in world.GetComponentsInChildren<Light>())
|
||||
{
|
||||
if (light.type == LightType.Directional) suns++;
|
||||
if (light.type == LightType.Spot && light.enabled) spots++;
|
||||
}
|
||||
foreach (var renderer in world.GetComponentsInChildren<Renderer>())
|
||||
if (renderer.enabled && renderer.shadowCastingMode == ShadowCastingMode.ShadowsOnly) shadowOnly++;
|
||||
int probes = world.GetComponentsInChildren<ReflectionProbe>().Length;
|
||||
int cameras = world.GetComponentsInChildren<Camera>().Length;
|
||||
result.rows.Add(new Row { phase = phase, chassis = GameSession.Active.SelectedChassis, worlds = roots, suns = suns, enabledSpots = spots, probes = probes, cameras = cameras, shadowOnlyRenderers = shadowOnly, allocatedMiB = Profiler.GetTotalAllocatedMemoryLong() / 1048576.0, reflectionReady = world.GetComponent<WorldLighting>().ReflectionReady, passed = roots == 1 && suns == 1 && probes == 1 && cameras == 1 && spots <= 1 && (!requireShadowOnly || shadowOnly > 0) });
|
||||
}
|
||||
|
||||
static void Finish()
|
||||
{
|
||||
EditorApplication.update -= Tick;
|
||||
if (GameSession.Active) GameSession.Active.SelectedChassis = initialChassis;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Output));
|
||||
File.WriteAllText(Output, JsonUtility.ToJson(result, true));
|
||||
result = null;
|
||||
}
|
||||
|
||||
[Serializable] sealed class Result { public bool passed; public string error; public List<Row> rows = new List<Row>(); }
|
||||
[Serializable] sealed class Row
|
||||
{
|
||||
public string phase;
|
||||
public int chassis, worlds, suns, enabledSpots, probes, cameras, shadowOnlyRenderers;
|
||||
public double allocatedMiB;
|
||||
public bool passed, reflectionReady;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cb5cce526da7a4244acad7c61bb89fd5
|
||||
@@ -53,6 +53,9 @@ namespace Ironspore.Editor
|
||||
AssetDatabase.AddObjectToAsset(uiFont.material, uiFont);
|
||||
}
|
||||
uiFont.isMultiAtlasTexturesEnabled = true;
|
||||
var fontSettings = new SerializedObject(uiFont);
|
||||
fontSettings.FindProperty("m_ClearDynamicDataOnBuild").boolValue = false;
|
||||
fontSettings.ApplyModifiedPropertiesWithoutUndo();
|
||||
var characters = new HashSet<char>(Enumerable.Range(32, 95).Select(x => (char)x));
|
||||
foreach (var locale in catalog.locales)
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace Ironspore.Editor
|
||||
// Editor-only integration checks and real Game View captures, invoked through Unity MCP.
|
||||
public static class LocalizationSmoke
|
||||
{
|
||||
const string Folder = "Docs/QA/Localization";
|
||||
static string Folder;
|
||||
static readonly List<Action> steps = new List<Action>();
|
||||
static readonly Dictionary<string, byte[]> rangeFiles = new Dictionary<string, byte[]>();
|
||||
static Result result;
|
||||
@@ -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]
|
||||
@@ -82,8 +82,9 @@ namespace Ironspore.Editor
|
||||
var button = Root.Q<Button>("language-toggle").worldBound;
|
||||
CheckThat(name + " button remains bottom-right", button.width > 200 && button.xMin > outer.center.x && button.xMax <= outer.xMax && button.yMax <= outer.yMax && outer.xMax - button.xMax < 80 && outer.yMax - button.yMax < 80);
|
||||
}
|
||||
public static string Begin()
|
||||
public static string Begin(string outputFolder = "Docs/QA/Localization")
|
||||
{
|
||||
Folder = outputFolder;
|
||||
if (!EditorApplication.isPlaying || !GameSession.Active || GameSession.Active.InField)
|
||||
throw new InvalidOperationException("Run the localization smoke from the playing hangar.");
|
||||
if (result != null)
|
||||
@@ -97,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");
|
||||
|
||||
@@ -139,6 +139,7 @@ namespace Ironspore.Editor
|
||||
session.chassis = frames;
|
||||
session.equipment = parts;
|
||||
session.mechModel = prefab;
|
||||
session.lightingProfile = AssetDatabase.LoadAssetAtPath<WorldLightingProfile>("Assets/Game/Data/Lighting/Daylight.asset");
|
||||
session.inputTemplate = input;
|
||||
session.uiStyle = AssetDatabase.LoadAssetAtPath<StyleSheet>("Assets/Game/UI/GameHud.uss");
|
||||
session.uiPanel = panelSettings;
|
||||
|
||||
@@ -15,9 +15,10 @@ namespace Ironspore.Editor
|
||||
static Vector3 initial;
|
||||
static bool cockpitSeen, shoulderCaptured, cockpitCaptured, traceSeen;
|
||||
static float minimumEnergy;
|
||||
static readonly string Folder = "Docs/QA/Prototype";
|
||||
public static string Begin()
|
||||
static string Folder;
|
||||
public static string Begin(string outputFolder = "Docs/QA/Prototype")
|
||||
{
|
||||
Folder = outputFolder;
|
||||
if (!EditorApplication.isPlaying)
|
||||
throw new Exception("Play mode required");
|
||||
var game = GameSession.Active;
|
||||
@@ -34,6 +35,7 @@ namespace Ironspore.Editor
|
||||
cockpitSeen = shoulderCaptured = cockpitCaptured = traceSeen = false;
|
||||
minimumEnergy = 100;
|
||||
Directory.CreateDirectory(Folder + "/Screenshots");
|
||||
Directory.CreateDirectory(Folder + "/TestResults");
|
||||
EditorApplication.update += Tick;
|
||||
return "Keyboard/mouse playback started. Report in 8 seconds.";
|
||||
}
|
||||
|
||||
@@ -14,8 +14,10 @@ namespace Ironspore.Editor
|
||||
static int step;
|
||||
static ProfileStore testStore;
|
||||
static Result result;
|
||||
public static string Begin()
|
||||
static string folder;
|
||||
public static string Begin(string outputFolder = "Docs/QA/Prototype")
|
||||
{
|
||||
folder = outputFolder;
|
||||
var game = GameSession.Active;
|
||||
if (!EditorApplication.isPlaying || !game)
|
||||
throw new Exception("Play mode required");
|
||||
@@ -142,8 +144,8 @@ namespace Ironspore.Editor
|
||||
if (keyboard != null && keyboard.added)
|
||||
InputSystem.RemoveDevice(keyboard);
|
||||
keyboard = null;
|
||||
Directory.CreateDirectory("Docs/QA/Prototype/TestResults");
|
||||
File.WriteAllText("Docs/QA/Prototype/TestResults/RecoveryPlayback.json", JsonUtility.ToJson(result, true));
|
||||
Directory.CreateDirectory(folder + "/TestResults");
|
||||
File.WriteAllText(folder + "/TestResults/RecoveryPlayback.json", JsonUtility.ToJson(result, true));
|
||||
}
|
||||
[Serializable]
|
||||
sealed class Result
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 40f1b8fabeb8ff94793cab083ececc2c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,33 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 90562a900c2114d48bd80969332d5ec6, type: 3}
|
||||
m_Name: Daylight
|
||||
m_EditorClassIdentifier: Ironspore.Runtime::Ironspore.WorldLightingProfile
|
||||
sunEuler: {x: 48, y: -35, z: 0}
|
||||
sunColor: {r: 1, g: 0.94, b: 0.84, a: 1}
|
||||
sunIntensity: 1.2
|
||||
hangarSunIntensity: 0.8
|
||||
ambientSky: {r: 0.24, g: 0.3, b: 0.38, a: 1}
|
||||
ambientEquator: {r: 0.12, g: 0.16, b: 0.19, a: 1}
|
||||
ambientGround: {r: 0.045, g: 0.055, b: 0.065, a: 1}
|
||||
fogColor: {r: 0.15, g: 0.21, b: 0.26, a: 1}
|
||||
fogStart: 38
|
||||
fogEnd: 145
|
||||
skyExposure: 0.85
|
||||
reflectionIntensity: 0.7
|
||||
bias: 0.08
|
||||
normalBias: 0.4
|
||||
shadowNearPlane: 0.2
|
||||
navigationIntensity: 6
|
||||
extractionIntensity: 6
|
||||
hangarIntensity: 5
|
||||
localLightCullDistance: 50
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8a14acd5fe57dab45b31eb59cc0c473a
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -14,7 +14,7 @@ Material:
|
||||
m_ValidKeywords: []
|
||||
m_InvalidKeywords: []
|
||||
m_LightmapFlags: 4
|
||||
m_EnableInstancingVariants: 0
|
||||
m_EnableInstancingVariants: 1
|
||||
m_DoubleSidedGI: 0
|
||||
m_CustomRenderQueue: -1
|
||||
stringTagMap: {}
|
||||
@@ -66,9 +66,9 @@ Material:
|
||||
- _DetailNormalMapScale: 1
|
||||
- _DstBlend: 0
|
||||
- _GlossMapScale: 1
|
||||
- _Glossiness: 0.38
|
||||
- _Glossiness: 0.32
|
||||
- _GlossyReflections: 1
|
||||
- _Metallic: 0.55
|
||||
- _Metallic: 0.04
|
||||
- _Mode: 0
|
||||
- _OcclusionStrength: 1
|
||||
- _Parallax: 0.02
|
||||
|
||||
@@ -14,7 +14,7 @@ Material:
|
||||
m_ValidKeywords: []
|
||||
m_InvalidKeywords: []
|
||||
m_LightmapFlags: 4
|
||||
m_EnableInstancingVariants: 0
|
||||
m_EnableInstancingVariants: 1
|
||||
m_DoubleSidedGI: 0
|
||||
m_CustomRenderQueue: -1
|
||||
stringTagMap: {}
|
||||
@@ -66,9 +66,9 @@ Material:
|
||||
- _DetailNormalMapScale: 1
|
||||
- _DstBlend: 0
|
||||
- _GlossMapScale: 1
|
||||
- _Glossiness: 0.38
|
||||
- _Glossiness: 0.4
|
||||
- _GlossyReflections: 1
|
||||
- _Metallic: 0.55
|
||||
- _Metallic: 0.78
|
||||
- _Mode: 0
|
||||
- _OcclusionStrength: 1
|
||||
- _Parallax: 0.02
|
||||
|
||||
@@ -14,7 +14,7 @@ Material:
|
||||
m_ValidKeywords: []
|
||||
m_InvalidKeywords: []
|
||||
m_LightmapFlags: 4
|
||||
m_EnableInstancingVariants: 0
|
||||
m_EnableInstancingVariants: 1
|
||||
m_DoubleSidedGI: 0
|
||||
m_CustomRenderQueue: -1
|
||||
stringTagMap: {}
|
||||
@@ -66,9 +66,9 @@ Material:
|
||||
- _DetailNormalMapScale: 1
|
||||
- _DstBlend: 0
|
||||
- _GlossMapScale: 1
|
||||
- _Glossiness: 0.38
|
||||
- _Glossiness: 0.32
|
||||
- _GlossyReflections: 1
|
||||
- _Metallic: 0.55
|
||||
- _Metallic: 0.04
|
||||
- _Mode: 0
|
||||
- _OcclusionStrength: 1
|
||||
- _Parallax: 0.02
|
||||
|
||||
@@ -14,7 +14,7 @@ Material:
|
||||
m_ValidKeywords: []
|
||||
m_InvalidKeywords: []
|
||||
m_LightmapFlags: 4
|
||||
m_EnableInstancingVariants: 0
|
||||
m_EnableInstancingVariants: 1
|
||||
m_DoubleSidedGI: 0
|
||||
m_CustomRenderQueue: -1
|
||||
stringTagMap: {}
|
||||
@@ -66,9 +66,9 @@ Material:
|
||||
- _DetailNormalMapScale: 1
|
||||
- _DstBlend: 0
|
||||
- _GlossMapScale: 1
|
||||
- _Glossiness: 0.38
|
||||
- _Glossiness: 0.48
|
||||
- _GlossyReflections: 1
|
||||
- _Metallic: 0.55
|
||||
- _Metallic: 0.85
|
||||
- _Mode: 0
|
||||
- _OcclusionStrength: 1
|
||||
- _Parallax: 0.02
|
||||
|
||||
@@ -1,5 +1,77 @@
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"key": "range.choose",
|
||||
"value": "Left: targets. Right: timed gates. Beyond the targets: salvage."
|
||||
},
|
||||
{
|
||||
"key": "range.start",
|
||||
"value": "01 / START"
|
||||
},
|
||||
{
|
||||
"key": "range.upper",
|
||||
"value": "02 / RAMP"
|
||||
},
|
||||
{
|
||||
"key": "range.landing",
|
||||
"value": "BRAKE HERE"
|
||||
},
|
||||
{
|
||||
"key": "range.gallery",
|
||||
"value": "TARGET RANGE"
|
||||
},
|
||||
{
|
||||
"key": "range.salvage",
|
||||
"value": "SALVAGE BAY"
|
||||
},
|
||||
{
|
||||
"key": "range.material",
|
||||
"value": "MATERIALS"
|
||||
},
|
||||
{
|
||||
"key": "range.parts",
|
||||
"value": "FIELD PARTS"
|
||||
},
|
||||
{
|
||||
"key": "range.targetGuide",
|
||||
"value": "Hit both targets. Hits: {0}. Use the reset desk to retry."
|
||||
},
|
||||
{
|
||||
"key": "range.brake",
|
||||
"value": "Stop inside the stripes at the far end of the platform."
|
||||
},
|
||||
{
|
||||
"key": "range.finish",
|
||||
"value": "Course time: {0:F1} s. Cross the start gate again to retry."
|
||||
},
|
||||
{
|
||||
"key": "range.recoveryGuide",
|
||||
"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 [{0}]."
|
||||
},
|
||||
{
|
||||
"key": "range.board",
|
||||
"value": "Transport ready. Board at the orange pad [{0}]."
|
||||
},
|
||||
{
|
||||
"key": "range.reset",
|
||||
"value": "Reset drills [{0}]"
|
||||
},
|
||||
{
|
||||
"key": "range.gate",
|
||||
"value": "Gate cleared. Follow the lane."
|
||||
},
|
||||
{
|
||||
"key": "range.hit",
|
||||
"value": "Target hits: {0}"
|
||||
},
|
||||
{
|
||||
"key": "range.ready",
|
||||
"value": "Training equipment reset."
|
||||
},
|
||||
{
|
||||
"key": "ui.home.subtitle",
|
||||
"value": "MECH RECOVERY OPERATIONS"
|
||||
@@ -46,7 +118,7 @@
|
||||
},
|
||||
{
|
||||
"key": "ui.home.locked",
|
||||
"value": "Selected advanced part needs schematic research. Try any loadout in the driving range."
|
||||
"value": "Selected gear is out of stock. Standard slots can use loaners. All models are available in the range."
|
||||
},
|
||||
{
|
||||
"key": "ui.home.moveHint",
|
||||
@@ -158,7 +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",
|
||||
@@ -174,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 {0} 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",
|
||||
@@ -194,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",
|
||||
@@ -290,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",
|
||||
@@ -298,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",
|
||||
@@ -310,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",
|
||||
@@ -334,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",
|
||||
@@ -536,6 +608,54 @@
|
||||
"key": "item.blueprint_reactive_guard",
|
||||
"value": "SCHEMATIC / REACTIVE SHIELD"
|
||||
},
|
||||
{
|
||||
"key": "ui.cargo.hold",
|
||||
"value": "Cargo hold · 4×4"
|
||||
},
|
||||
{
|
||||
"key": "ui.cargo.case",
|
||||
"value": "Ejection safe case · 2×2"
|
||||
},
|
||||
{
|
||||
"key": "ui.cargo.selection",
|
||||
"value": "Selected {0} {1}×{2} cells Quantity {3}"
|
||||
},
|
||||
{
|
||||
"key": "ui.cargo.rotate",
|
||||
"value": "Rotate (R)"
|
||||
},
|
||||
{
|
||||
"key": "ui.cargo.cancel",
|
||||
"value": "Deselect"
|
||||
},
|
||||
{
|
||||
"key": "ui.cargo.install",
|
||||
"value": "Install"
|
||||
},
|
||||
{
|
||||
"key": "ui.cargo.mark",
|
||||
"value": "Mark for pod"
|
||||
},
|
||||
{
|
||||
"key": "ui.cargo.unmark",
|
||||
"value": "Unmark"
|
||||
},
|
||||
{
|
||||
"key": "notice.installFailed",
|
||||
"value": "Cannot install. Check the loadout budget and the space for the replaced part."
|
||||
},
|
||||
{
|
||||
"key": "notice.placement",
|
||||
"value": "Cannot place here. Check bounds, occupied cells and stack quantity."
|
||||
},
|
||||
{
|
||||
"key": "ui.home.chooseItem",
|
||||
"value": "Equip {0} · {1}"
|
||||
},
|
||||
{
|
||||
"key": "ui.home.manufacture",
|
||||
"value": "Build {0} · {1} alloy / {2} cells"
|
||||
},
|
||||
{
|
||||
"key": "settings.title",
|
||||
"value": "SETTINGS"
|
||||
@@ -850,7 +970,15 @@
|
||||
},
|
||||
{
|
||||
"key": "ui.combat.cargo",
|
||||
"value": "Cargo {0} / 4"
|
||||
"value": "Hold {0}/16 cells · Safe case {1}/4 cells"
|
||||
},
|
||||
{
|
||||
"key": "ui.combat.empty",
|
||||
"value": "Empty slot"
|
||||
},
|
||||
{
|
||||
"key": "range.resetSign",
|
||||
"value": "RESET DESK"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,77 @@
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"key": "range.choose",
|
||||
"value": "左侧打靶,右侧计时穿门。越过靶区可练习回收。"
|
||||
},
|
||||
{
|
||||
"key": "range.start",
|
||||
"value": "01 / 计时起点"
|
||||
},
|
||||
{
|
||||
"key": "range.upper",
|
||||
"value": "02 / 高台门"
|
||||
},
|
||||
{
|
||||
"key": "range.landing",
|
||||
"value": "在标线内停稳"
|
||||
},
|
||||
{
|
||||
"key": "range.gallery",
|
||||
"value": "射击训练"
|
||||
},
|
||||
{
|
||||
"key": "range.salvage",
|
||||
"value": "回收练习区"
|
||||
},
|
||||
{
|
||||
"key": "range.material",
|
||||
"value": "材料示范箱"
|
||||
},
|
||||
{
|
||||
"key": "range.parts",
|
||||
"value": "试装部件箱"
|
||||
},
|
||||
{
|
||||
"key": "range.targetGuide",
|
||||
"value": "射击翻板和横移靶。已命中 {0} 次。到复位台可重练。"
|
||||
},
|
||||
{
|
||||
"key": "range.brake",
|
||||
"value": "在高台尽头的标线内停稳。"
|
||||
},
|
||||
{
|
||||
"key": "range.finish",
|
||||
"value": "本轮用时 {0:F1} 秒。再次穿过起点可重练。"
|
||||
},
|
||||
{
|
||||
"key": "range.recoveryGuide",
|
||||
"value": "按 [{0}] 搜索箱子,按 [{1}] 选择货物,原地按住 [{2}] 发射。"
|
||||
},
|
||||
{
|
||||
"key": "range.recoverySent",
|
||||
"value": "货舱已发射。前往橙色平台按 [{0}] 呼叫运输机。"
|
||||
},
|
||||
{
|
||||
"key": "range.board",
|
||||
"value": "运输机已抵达。前往橙色平台按 [{0}] 登机。"
|
||||
},
|
||||
{
|
||||
"key": "range.reset",
|
||||
"value": "重置练习 [{0}]"
|
||||
},
|
||||
{
|
||||
"key": "range.gate",
|
||||
"value": "已通过起点,沿标线前进。"
|
||||
},
|
||||
{
|
||||
"key": "range.hit",
|
||||
"value": "靶标命中 {0} 次"
|
||||
},
|
||||
{
|
||||
"key": "range.ready",
|
||||
"value": "训练设备已复位。"
|
||||
},
|
||||
{
|
||||
"key": "ui.home.subtitle",
|
||||
"value": "机甲回收行动"
|
||||
@@ -46,7 +118,7 @@
|
||||
},
|
||||
{
|
||||
"key": "ui.home.locked",
|
||||
"value": "所选高级部件尚未研究蓝图。可在驾驶试验场试用任意配装。"
|
||||
"value": "所选装备库存不足。基础槽位可借用救济装,试验场可试用所有型号。"
|
||||
},
|
||||
{
|
||||
"key": "ui.home.moveHint",
|
||||
@@ -158,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",
|
||||
@@ -174,15 +246,15 @@
|
||||
},
|
||||
{
|
||||
"key": "ui.cargo.title",
|
||||
"value": "随身货仓"
|
||||
"value": "货仓与弹射式安全箱"
|
||||
},
|
||||
{
|
||||
"key": "ui.cargo.used",
|
||||
"value": "回收舱已使用 / 剩余货物需要随整机撤离。"
|
||||
"value": "主动货舱已发射。机毁时只有弹射式安全箱中的物品返航。"
|
||||
},
|
||||
{
|
||||
"key": "ui.cargo.help",
|
||||
"value": "最多选择两组货物。关闭面板后原地按住 {0} 3 秒发射回收舱。"
|
||||
"value": "拖动物品或选中后点击目标格,按 R 旋转。安全箱在机毁时自动返航。标记货物后关闭面板,原地按住 {0} 三秒发射主动货舱。"
|
||||
},
|
||||
{
|
||||
"key": "ui.cargo.stack",
|
||||
@@ -194,7 +266,7 @@
|
||||
},
|
||||
{
|
||||
"key": "ui.cargo.capacity",
|
||||
"value": "随身货仓 {0} / 4 回收舱 {1} / 2"
|
||||
"value": "普通货仓 {0}/16 格 主动货舱 {1}/4 格"
|
||||
},
|
||||
{
|
||||
"key": "ui.return",
|
||||
@@ -290,7 +362,7 @@
|
||||
},
|
||||
{
|
||||
"key": "report.destroyed",
|
||||
"value": "机甲损失 / 丢失 {0} 组随身货物。已送回的货物仍保留在机库。"
|
||||
"value": "机体损毁。损失 {0} 件装备或货物,弹射式安全箱送回 {1} 件物品。"
|
||||
},
|
||||
{
|
||||
"key": "report.complete",
|
||||
@@ -298,7 +370,7 @@
|
||||
},
|
||||
{
|
||||
"key": "report.extracted",
|
||||
"value": "提前撤离 / 随身材料与蓝图已全部入库。"
|
||||
"value": "撤离成功。已装装备、普通货仓和安全箱物品已回收。"
|
||||
},
|
||||
{
|
||||
"key": "notice.unfinished",
|
||||
@@ -310,7 +382,7 @@
|
||||
},
|
||||
{
|
||||
"key": "notice.researchFirst",
|
||||
"value": "请先研究所选装备蓝图,或在驾驶试验场试用。"
|
||||
"value": "机库中没有所选装备。"
|
||||
},
|
||||
{
|
||||
"key": "notice.saveFailed",
|
||||
@@ -334,7 +406,7 @@
|
||||
},
|
||||
{
|
||||
"key": "notice.twoSlots",
|
||||
"value": "回收舱最多容纳两组货物。"
|
||||
"value": "所选物品放不进 2×2 主动货舱。"
|
||||
},
|
||||
{
|
||||
"key": "notice.holdFull",
|
||||
@@ -536,6 +608,54 @@
|
||||
"key": "item.blueprint_reactive_guard",
|
||||
"value": "蓝图 / 反应护盾"
|
||||
},
|
||||
{
|
||||
"key": "ui.cargo.hold",
|
||||
"value": "普通货仓 · 4×4"
|
||||
},
|
||||
{
|
||||
"key": "ui.cargo.case",
|
||||
"value": "弹射式安全箱 · 2×2"
|
||||
},
|
||||
{
|
||||
"key": "ui.cargo.selection",
|
||||
"value": "已选 {0} {1}×{2} 格 移动数量 {3}"
|
||||
},
|
||||
{
|
||||
"key": "ui.cargo.rotate",
|
||||
"value": "旋转 (R)"
|
||||
},
|
||||
{
|
||||
"key": "ui.cargo.cancel",
|
||||
"value": "取消选择"
|
||||
},
|
||||
{
|
||||
"key": "ui.cargo.install",
|
||||
"value": "安装"
|
||||
},
|
||||
{
|
||||
"key": "ui.cargo.mark",
|
||||
"value": "标记发射"
|
||||
},
|
||||
{
|
||||
"key": "ui.cargo.unmark",
|
||||
"value": "取消标记"
|
||||
},
|
||||
{
|
||||
"key": "notice.installFailed",
|
||||
"value": "无法安装。请检查机体预算和旧件所需的空间。"
|
||||
},
|
||||
{
|
||||
"key": "notice.placement",
|
||||
"value": "无法放置。请检查边界、占用和堆叠数量。"
|
||||
},
|
||||
{
|
||||
"key": "ui.home.chooseItem",
|
||||
"value": "选用 {0} · {1}"
|
||||
},
|
||||
{
|
||||
"key": "ui.home.manufacture",
|
||||
"value": "制造 {0} · 合金 {1} / 电芯 {2}"
|
||||
},
|
||||
{
|
||||
"key": "settings.title",
|
||||
"value": "设置"
|
||||
@@ -850,7 +970,15 @@
|
||||
},
|
||||
{
|
||||
"key": "ui.combat.cargo",
|
||||
"value": "货舱 {0} / 4"
|
||||
"value": "普通货仓 {0}/16 格 · 安全箱 {1}/4 格"
|
||||
},
|
||||
{
|
||||
"key": "ui.combat.empty",
|
||||
"value": "未安装"
|
||||
},
|
||||
{
|
||||
"key": "range.resetSign",
|
||||
"value": "训练复位台"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace Ironspore
|
||||
{
|
||||
public sealed class EnemyUnit : MonoBehaviour
|
||||
{
|
||||
// DynamicProps keeps its original physical obstruction semantics after render-layer separation.
|
||||
const int WorldObstructionMask = (1 << 0) | (1 << 10);
|
||||
GameSession game;
|
||||
float health, cooldown, age;
|
||||
int type;
|
||||
@@ -41,7 +43,7 @@ namespace Ironspore
|
||||
if (type != 1 && distance > 10)
|
||||
{
|
||||
Vector3 step = flat.normalized * Time.deltaTime * (type == 3 ? 1.4f : type == 0 ? 2.5f : 1.2f);
|
||||
if (!Physics.Raycast(transform.position + Vector3.up, step.normalized, 1.1f, 1, QueryTriggerInteraction.Ignore))
|
||||
if (!Physics.Raycast(transform.position + Vector3.up, step.normalized, 1.1f, WorldObstructionMask, QueryTriggerInteraction.Ignore))
|
||||
transform.position += step;
|
||||
}
|
||||
if (type == 0)
|
||||
@@ -54,7 +56,7 @@ namespace Ironspore
|
||||
if (cooldown <= 0 && distance < 42)
|
||||
{
|
||||
Vector3 origin = transform.position + Vector3.up * (type == 3 ? 3.5f : 2);
|
||||
if (!Physics.Linecast(origin, target, 1, QueryTriggerInteraction.Ignore))
|
||||
if (!Physics.Linecast(origin, target, WorldObstructionMask, QueryTriggerInteraction.Ignore))
|
||||
{
|
||||
var bolt = WorldFactory.Primitive(PrimitiveType.Sphere, "EnemyBolt", origin, Vector3.one * .3f, new Color(1, .35f, .08f), null, false, true);
|
||||
bolt.AddComponent<EnemyBolt>().Initialize((target - origin).normalized, type == 3 ? 23 : 12);
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
|
||||
namespace Ironspore
|
||||
{
|
||||
public static class WorldFactory
|
||||
{
|
||||
static readonly Dictionary<string, Material> materials = new Dictionary<string, Material>();
|
||||
public static Material Material(Color color, bool glow = false)
|
||||
public enum Surface { Painted, Concrete, BareMetal }
|
||||
public static Material Material(Color color, bool glow = false, Surface surface = Surface.Painted)
|
||||
{
|
||||
string key = ColorUtility.ToHtmlStringRGB(color) + (glow ? "g" : "");
|
||||
string key = ColorUtility.ToHtmlStringRGB(color) + (glow ? "g" : "") + (int)surface;
|
||||
if (materials.TryGetValue(key, out var cached) && cached)
|
||||
return cached;
|
||||
var mat = new Material(Shader.Find("Standard"));
|
||||
mat.color = color;
|
||||
mat.SetFloat("_Metallic", .3f);
|
||||
mat.SetFloat("_Glossiness", .35f);
|
||||
mat.SetFloat("_Metallic", surface == Surface.BareMetal ? .78f : surface == Surface.Concrete ? 0 : .04f);
|
||||
mat.SetFloat("_Glossiness", surface == Surface.Concrete ? .15f : surface == Surface.BareMetal ? .48f : .32f);
|
||||
mat.enableInstancing = true;
|
||||
if (glow)
|
||||
{
|
||||
mat.EnableKeyword("_EMISSION");
|
||||
mat.SetColor("_EmissionColor", color * 1.8f);
|
||||
mat.SetColor("_EmissionColor", color * 1.25f);
|
||||
}
|
||||
materials[key] = mat;
|
||||
return mat;
|
||||
@@ -38,7 +41,13 @@ namespace Ironspore
|
||||
obj.transform.SetParent(parent);
|
||||
obj.transform.position = position;
|
||||
obj.transform.localScale = scale;
|
||||
obj.GetComponent<Renderer>().sharedMaterial = Material(color, glow);
|
||||
var renderer = obj.GetComponent<Renderer>();
|
||||
var surface = name == "Ground" || name == "AccessRamp" ? Surface.Concrete : name == "ContainerRib" || name == "CanopyRail" ? Surface.BareMetal : Surface.Painted;
|
||||
renderer.sharedMaterial = Material(color, glow, surface);
|
||||
bool detail = name == "FloorSeam" || name == "AmberStripe" || name == "CrateLatch" || name == "RelayScreen" || name == "LandingLight" || name == "CanopyIndicator" || name.EndsWith("WorkFixture");
|
||||
renderer.shadowCastingMode = detail || name == "EnemyBolt" ? ShadowCastingMode.Off : ShadowCastingMode.On;
|
||||
renderer.receiveShadows = !glow;
|
||||
if (name == "EnemyBolt") obj.layer = 10;
|
||||
if (!collision)
|
||||
Object.Destroy(obj.GetComponent<Collider>());
|
||||
return obj;
|
||||
@@ -59,6 +68,12 @@ namespace Ironspore
|
||||
dashboard.transform.localPosition = new Vector3(0, -.42f, .75f);
|
||||
foreach (var child in root.GetComponentsInChildren<Transform>())
|
||||
child.gameObject.layer = 8;
|
||||
foreach (var renderer in root.GetComponentsInChildren<Renderer>())
|
||||
{
|
||||
renderer.shadowCastingMode = ShadowCastingMode.Off;
|
||||
renderer.lightProbeUsage = LightProbeUsage.Off;
|
||||
renderer.reflectionProbeUsage = ReflectionProbeUsage.Off;
|
||||
}
|
||||
root.SetActive(false);
|
||||
return root;
|
||||
}
|
||||
@@ -67,11 +82,17 @@ namespace Ironspore
|
||||
var obj = new GameObject("Sign");
|
||||
obj.transform.SetParent(parent);
|
||||
obj.transform.position = position;
|
||||
obj.layer = 11;
|
||||
var label = obj.AddComponent<TextMesh>();
|
||||
label.fontSize = 40;
|
||||
label.characterSize = .09f;
|
||||
label.anchor = TextAnchor.MiddleCenter;
|
||||
label.color = color;
|
||||
var renderer = obj.GetComponent<Renderer>();
|
||||
renderer.shadowCastingMode = ShadowCastingMode.Off;
|
||||
renderer.receiveShadows = false;
|
||||
renderer.lightProbeUsage = LightProbeUsage.Off;
|
||||
renderer.reflectionProbeUsage = ReflectionProbeUsage.Off;
|
||||
obj.AddComponent<LocalizedWorldLabel>().Initialize(key);
|
||||
obj.AddComponent<FaceCamera>();
|
||||
}
|
||||
@@ -79,6 +100,11 @@ namespace Ironspore
|
||||
{
|
||||
var obj = new GameObject("ShotTrace");
|
||||
var line = obj.AddComponent<LineRenderer>();
|
||||
obj.layer = 11;
|
||||
line.shadowCastingMode = ShadowCastingMode.Off;
|
||||
line.receiveShadows = false;
|
||||
line.lightProbeUsage = LightProbeUsage.Off;
|
||||
line.reflectionProbeUsage = ReflectionProbeUsage.Off;
|
||||
line.positionCount = 2;
|
||||
line.SetPosition(0, from);
|
||||
line.SetPosition(1, to);
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,408 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.InputSystem.LowLevel;
|
||||
using UnityEngine.Profiling;
|
||||
using UnityEngine.Rendering;
|
||||
|
||||
namespace Ironspore
|
||||
{
|
||||
// Opt-in player QA. Normal launches never instantiate this component.
|
||||
public sealed class LightingValidation : MonoBehaviour
|
||||
{
|
||||
const BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic;
|
||||
static readonly FieldInfo Yaw = typeof(MechPilot).GetField("yaw", Private);
|
||||
static readonly FieldInfo Pitch = typeof(MechPilot).GetField("pitch", Private);
|
||||
static readonly PropertyInfo View = typeof(MechPilot).GetProperty("FirstPerson");
|
||||
static readonly Vector3[] Route = { new Vector3(3, 0, -18), new Vector3(26, 0, -18), new Vector3(26, 0, 38), new Vector3(3, 0, 38), new Vector3(3, 0, -18), new Vector3(-26, 0, -18), new Vector3(-26, 0, 38), new Vector3(3, 0, 38) };
|
||||
readonly FrameTiming[] timing = new FrameTiming[1];
|
||||
readonly List<Sample> samples = new List<Sample>(40000);
|
||||
readonly Report report = new Report();
|
||||
GameSession game;
|
||||
Keyboard keyboard;
|
||||
Mouse mouse;
|
||||
Camera benchmarkCamera;
|
||||
int renderedFrames;
|
||||
string folder;
|
||||
bool measuring;
|
||||
double previous;
|
||||
ulong lastTiming;
|
||||
int waypoint;
|
||||
int visitedWaypoints, segmentWaypoints;
|
||||
float travelled, segmentTravelled;
|
||||
bool touring;
|
||||
Color32[] captured;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Launch()
|
||||
{
|
||||
if (!Debug.isDebugBuild || Array.IndexOf(Environment.GetCommandLineArgs(), "-lighting-qa") < 0)
|
||||
return;
|
||||
new GameObject("LightingValidation").AddComponent<LightingValidation>();
|
||||
}
|
||||
|
||||
static string Arg(string name, string fallback)
|
||||
{
|
||||
var args = Environment.GetCommandLineArgs();
|
||||
int index = Array.IndexOf(args, name);
|
||||
return index >= 0 && index + 1 < args.Length ? args[index + 1] : fallback;
|
||||
}
|
||||
|
||||
IEnumerator Start()
|
||||
{
|
||||
folder = Path.GetFullPath(Arg("-lighting-output", Path.Combine(Application.persistentDataPath, "LightingQA")));
|
||||
Directory.CreateDirectory(folder);
|
||||
Directory.CreateDirectory(Path.Combine(folder, "Screenshots"));
|
||||
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;
|
||||
while (!GameSession.Active)
|
||||
yield return null;
|
||||
game = GameSession.Active;
|
||||
Camera.onPostRender += CameraRendered;
|
||||
yield return null;
|
||||
string quality = Arg("-lighting-quality", "Medium");
|
||||
int level = Array.IndexOf(QualitySettings.names, quality);
|
||||
if (level < 0)
|
||||
throw new ArgumentException("Unknown quality: " + quality);
|
||||
QualitySettings.SetQualityLevel(level, true);
|
||||
game.World.BuildHangar();
|
||||
QualitySettings.vSyncCount = 0;
|
||||
Application.targetFrameRate = int.Parse(Arg("-lighting-cap", "-1"), CultureInfo.InvariantCulture);
|
||||
report.tag = Arg("-lighting-tag", "optimized");
|
||||
report.branch = int.Parse(Arg("-lighting-branch", "0"), CultureInfo.InvariantCulture);
|
||||
report.routeVersion = "perimeter-and-center-v2";
|
||||
report.startedUtc = DateTime.UtcNow.ToString("O");
|
||||
report.gpu = SystemInfo.graphicsDeviceName;
|
||||
report.api = SystemInfo.graphicsDeviceType.ToString();
|
||||
report.cpu = SystemInfo.processorType;
|
||||
report.gpuMemoryMiB = SystemInfo.graphicsMemorySize;
|
||||
report.systemMemoryMiB = SystemInfo.systemMemorySize;
|
||||
report.colorSpace = QualitySettings.activeColorSpace.ToString();
|
||||
report.quality = QualitySettings.names[level];
|
||||
report.shadowDistance = QualitySettings.shadowDistance;
|
||||
report.cascades = QualitySettings.shadowCascades;
|
||||
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");
|
||||
game.Input.devices = new InputDevice[] { keyboard, mouse };
|
||||
yield return new WaitForSecondsRealtime(2);
|
||||
yield return CaptureScreen("hangar");
|
||||
game.StartTrial();
|
||||
yield return new WaitForSecondsRealtime(2);
|
||||
report.dynamicResolution = game.Pilot.GetComponentInChildren<Camera>().allowDynamicResolution;
|
||||
report.renderWidthScale = ScalableBufferManager.widthScaleFactor;
|
||||
report.renderHeightScale = ScalableBufferManager.heightScaleFactor;
|
||||
report.sunResolution = GameObject.Find("Sun").GetComponent<Light>().shadowCustomResolution;
|
||||
yield return VisualChecks();
|
||||
if (Array.IndexOf(Environment.GetCommandLineArgs(), "-lighting-visual-only") < 0)
|
||||
{
|
||||
// Use the actual combat world, including the final-stage enemy population.
|
||||
game.EndSortie(true);
|
||||
game.BeginSortie();
|
||||
game.Store.Commit(save => { save.run.seed = 1739; save.run.stage = 4; save.run.branch = report.branch; save.run.armor = 100000; return true; });
|
||||
game.Resume();
|
||||
benchmarkCamera = game.Pilot.GetComponentInChildren<Camera>();
|
||||
report.cameraWidth = benchmarkCamera.pixelWidth;
|
||||
report.cameraHeight = benchmarkCamera.pixelHeight;
|
||||
yield return new WaitForSecondsRealtime(2);
|
||||
foreach (var enemy in FindObjectsByType<EnemyUnit>())
|
||||
// Keep the same combatants alive throughout every performance sample.
|
||||
typeof(EnemyUnit).GetField("health", Private).SetValue(enemy, 1000000f);
|
||||
game.World.CallExtraction();
|
||||
touring = true;
|
||||
waypoint = 0;
|
||||
double warmup = double.Parse(Arg("-lighting-warmup", "60"), CultureInfo.InvariantCulture);
|
||||
double duration = double.Parse(Arg("-lighting-seconds", "120"), CultureInfo.InvariantCulture);
|
||||
int repeats = int.Parse(Arg("-lighting-repeats", "3"), CultureInfo.InvariantCulture);
|
||||
report.warmupSeconds = warmup;
|
||||
report.sampleSeconds = duration;
|
||||
Debug.Log("Lighting QA warmup: " + warmup + " seconds");
|
||||
yield return Wait(warmup);
|
||||
for (int repeat = 0; repeat < repeats; repeat++)
|
||||
{
|
||||
for (int view = 0; view < 2; view++)
|
||||
{
|
||||
View.SetValue(game.Pilot, view == 1);
|
||||
yield return Wait(2);
|
||||
samples.Clear();
|
||||
renderedFrames = 0;
|
||||
segmentWaypoints = visitedWaypoints;
|
||||
segmentTravelled = travelled;
|
||||
previous = Time.realtimeSinceStartupAsDouble;
|
||||
measuring = true;
|
||||
yield return Wait(duration);
|
||||
measuring = false;
|
||||
string name = (view == 0 ? "shoulder" : "cockpit") + "-" + (repeat + 1);
|
||||
WriteSegment(name);
|
||||
yield return CaptureScreen(name);
|
||||
Debug.Log("Lighting QA completed " + name);
|
||||
}
|
||||
}
|
||||
touring = false;
|
||||
}
|
||||
report.width = Screen.width;
|
||||
report.height = Screen.height;
|
||||
report.finishedUtc = DateTime.UtcNow.ToString("O");
|
||||
report.completed = true;
|
||||
report.routeCompleted = game.InField && game.Run.routeCompleted;
|
||||
SaveReport();
|
||||
if (game.InField) game.EndSortie(true);
|
||||
Debug.Log("Lighting QA finished: " + Path.Combine(folder, "report.json"));
|
||||
if (!Application.isEditor)
|
||||
Application.Quit();
|
||||
}
|
||||
|
||||
static IEnumerator Wait(double seconds)
|
||||
{
|
||||
double until = Time.realtimeSinceStartupAsDouble + seconds;
|
||||
while (Time.realtimeSinceStartupAsDouble < until)
|
||||
yield return null;
|
||||
}
|
||||
|
||||
IEnumerator VisualChecks()
|
||||
{
|
||||
var pilot = game.Pilot;
|
||||
var visual = pilot.transform.Find("MechVisual");
|
||||
var renderers = visual.GetComponentsInChildren<Renderer>(true);
|
||||
var sun = GameObject.Find("Sun").GetComponent<Light>();
|
||||
Pitch.SetValue(pilot, 25f);
|
||||
Yaw.SetValue(pilot, 0f);
|
||||
View.SetValue(pilot, false);
|
||||
yield return new WaitForSecondsRealtime(.25f);
|
||||
yield return CaptureScreen("shoulder");
|
||||
View.SetValue(pilot, true);
|
||||
yield return new WaitForSecondsRealtime(.25f);
|
||||
yield return CaptureScreen("cockpit");
|
||||
int activeCasters = 0;
|
||||
foreach (var renderer in renderers)
|
||||
if (renderer.enabled && renderer.gameObject.activeInHierarchy && renderer.shadowCastingMode == ShadowCastingMode.ShadowsOnly)
|
||||
activeCasters++;
|
||||
report.firstPersonShadowOnlyRenderers = activeCasters;
|
||||
// Hold the real pilot in first-person mode. The observer sees its shadow only.
|
||||
pilot.enabled = false;
|
||||
var cockpit = (GameObject)typeof(MechPilot).GetField("cockpit", Private).GetValue(pilot);
|
||||
cockpit.SetActive(false);
|
||||
var observerObject = new GameObject("LightingQaObserver");
|
||||
var observer = observerObject.AddComponent<Camera>();
|
||||
observer.enabled = false;
|
||||
observer.cullingMask = ~(1 << 11);
|
||||
observer.fieldOfView = 52;
|
||||
observer.nearClipPlane = .1f;
|
||||
observer.farClipPlane = 140;
|
||||
observer.transform.position = new Vector3(0, 9, -23);
|
||||
observer.transform.LookAt(new Vector3(0, 0, -15));
|
||||
yield return null;
|
||||
CaptureCamera(observer, "shadow-before-move");
|
||||
var before = captured;
|
||||
var controller = pilot.GetComponent<CharacterController>();
|
||||
controller.enabled = false;
|
||||
pilot.transform.position += Vector3.right * 2;
|
||||
controller.enabled = true;
|
||||
yield return null;
|
||||
CaptureCamera(observer, "shadow-after-move");
|
||||
report.movingShadowPixels = DifferentPixels(before, captured);
|
||||
var oldShadows = sun.shadows;
|
||||
sun.shadows = LightShadows.None;
|
||||
yield return null;
|
||||
CaptureCamera(observer, "sun-shadows-disabled");
|
||||
var noShadows = captured;
|
||||
sun.shadows = oldShadows;
|
||||
yield return null;
|
||||
CaptureCamera(observer, "sun-shadows-enabled");
|
||||
report.sunShadowPixels = DifferentPixels(noShadows, captured);
|
||||
var localObject = GameObject.Find("NavigationWorkLight");
|
||||
if (localObject)
|
||||
{
|
||||
var local = localObject.GetComponent<Light>();
|
||||
controller.enabled = false;
|
||||
pilot.transform.position = new Vector3(0, .1f, 18);
|
||||
controller.enabled = true;
|
||||
observer.transform.position = new Vector3(7, 10, 13);
|
||||
observer.transform.LookAt(new Vector3(0, .3f, 21));
|
||||
yield return new WaitForSecondsRealtime(.25f);
|
||||
local.enabled = false;
|
||||
var manager = local.GetComponentInParent<MonoBehaviour>();
|
||||
if (manager && manager.GetType().Name == "WorldLighting")
|
||||
manager.enabled = false;
|
||||
CaptureCamera(observer, "local-off");
|
||||
var off = captured;
|
||||
local.enabled = true;
|
||||
CaptureCamera(observer, "local-on");
|
||||
var on = captured;
|
||||
var blocker = GameObject.CreatePrimitive(PrimitiveType.Cube);
|
||||
blocker.name = "LightingQaTemporaryBlocker";
|
||||
blocker.transform.position = local.transform.position + local.transform.forward * 1.1f;
|
||||
blocker.transform.localScale = new Vector3(1.4f, .15f, 1.4f);
|
||||
blocker.GetComponent<Collider>().enabled = false;
|
||||
local.enabled = false;
|
||||
yield return null;
|
||||
CaptureCamera(observer, "local-blocked-off");
|
||||
var blockedOff = captured;
|
||||
local.enabled = true;
|
||||
CaptureCamera(observer, "local-blocked-on");
|
||||
int lit = 0, blocked = 0;
|
||||
for (int i = 0; i < on.Length; i++)
|
||||
{
|
||||
int direct = Brightness(on[i]) - Brightness(off[i]);
|
||||
int obstructed = Brightness(captured[i]) - Brightness(blockedOff[i]);
|
||||
if (direct > 30) { lit++; if (obstructed < direct * .45f) blocked++; }
|
||||
}
|
||||
report.localLitPixels = lit;
|
||||
report.localBlockedPixels = blocked;
|
||||
report.localLightCastsShadows = local.shadows != LightShadows.None;
|
||||
Destroy(blocker);
|
||||
if (manager) manager.enabled = true;
|
||||
}
|
||||
Destroy(observerObject);
|
||||
pilot.enabled = true;
|
||||
report.visualPassed = activeCasters > 0 && report.movingShadowPixels > 50 && report.sunShadowPixels > 50 && report.localLightCastsShadows && report.localBlockedPixels > 50;
|
||||
SaveReport();
|
||||
}
|
||||
|
||||
static int Brightness(Color32 color) => color.r + color.g + color.b;
|
||||
static int DifferentPixels(Color32[] first, Color32[] second)
|
||||
{
|
||||
int changed = 0;
|
||||
for (int i = 0; i < first.Length; i++)
|
||||
if (Math.Abs(first[i].r - second[i].r) + Math.Abs(first[i].g - second[i].g) + Math.Abs(first[i].b - second[i].b) > 30)
|
||||
changed++;
|
||||
return changed;
|
||||
}
|
||||
|
||||
void CaptureCamera(Camera camera, string name)
|
||||
{
|
||||
var target = RenderTexture.GetTemporary(1920, 1080, 24, RenderTextureFormat.ARGB32);
|
||||
var oldTarget = camera.targetTexture;
|
||||
var oldActive = RenderTexture.active;
|
||||
camera.targetTexture = target;
|
||||
camera.Render();
|
||||
RenderTexture.active = target;
|
||||
var image = new Texture2D(1920, 1080, TextureFormat.RGB24, false);
|
||||
image.ReadPixels(new Rect(0, 0, 1920, 1080), 0, 0);
|
||||
image.Apply();
|
||||
captured = image.GetPixels32();
|
||||
File.WriteAllBytes(Path.Combine(folder, "Screenshots", name + ".png"), image.EncodeToPNG());
|
||||
camera.targetTexture = oldTarget;
|
||||
RenderTexture.active = oldActive;
|
||||
RenderTexture.ReleaseTemporary(target);
|
||||
Destroy(image);
|
||||
}
|
||||
|
||||
IEnumerator CaptureScreen(string name)
|
||||
{
|
||||
yield return new WaitForEndOfFrame();
|
||||
var image = ScreenCapture.CaptureScreenshotAsTexture();
|
||||
File.WriteAllBytes(Path.Combine(folder, "Screenshots", name + ".png"), image.EncodeToPNG());
|
||||
Destroy(image);
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (!touring || !game || !game.InField)
|
||||
return;
|
||||
var pilot = game.Pilot;
|
||||
var goal = Route[waypoint];
|
||||
var offset = goal - pilot.transform.position;
|
||||
offset.y = 0;
|
||||
if (offset.magnitude < 1.2f) { visitedWaypoints++; waypoint = (waypoint + 1) % Route.Length; offset = Route[waypoint] - pilot.transform.position; offset.y = 0; }
|
||||
Yaw.SetValue(pilot, Mathf.Atan2(offset.x, offset.z) * Mathf.Rad2Deg);
|
||||
Pitch.SetValue(pilot, 12f);
|
||||
var beforeMove = pilot.transform.position;
|
||||
pilot.GetComponent<CharacterController>().Move(offset.normalized * (3.5f * Time.deltaTime));
|
||||
travelled += Vector3.Distance(beforeMove, pilot.transform.position);
|
||||
var keys = new KeyboardState();
|
||||
if ((int)Time.realtimeSinceStartup % 9 == 0) keys.Press(Key.Space);
|
||||
InputSystem.QueueStateEvent(keyboard, keys);
|
||||
var buttons = new MouseState();
|
||||
if ((int)Time.realtimeSinceStartup % 3 == 0) buttons = buttons.WithButton(MouseButton.Left);
|
||||
InputSystem.QueueStateEvent(mouse, buttons);
|
||||
}
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
FrameTimingManager.CaptureFrameTimings();
|
||||
if (!measuring)
|
||||
return;
|
||||
double now = Time.realtimeSinceStartupAsDouble;
|
||||
var sample = new Sample { wallMs = (now - previous) * 1000 };
|
||||
previous = now;
|
||||
if (FrameTimingManager.GetLatestTimings(1, timing) > 0 && timing[0].frameStartTimestamp != lastTiming)
|
||||
{
|
||||
lastTiming = timing[0].frameStartTimestamp;
|
||||
sample.cpuMs = timing[0].cpuFrameTime;
|
||||
sample.gpuMs = timing[0].gpuFrameTime;
|
||||
sample.mainMs = timing[0].cpuMainThreadFrameTime;
|
||||
sample.renderMs = timing[0].cpuRenderThreadFrameTime;
|
||||
sample.presentWaitMs = timing[0].cpuMainThreadPresentWaitTime;
|
||||
}
|
||||
samples.Add(sample);
|
||||
}
|
||||
|
||||
void CameraRendered(Camera camera)
|
||||
{
|
||||
if (measuring && camera == benchmarkCamera) renderedFrames++;
|
||||
}
|
||||
|
||||
void WriteSegment(string name)
|
||||
{
|
||||
var wall = new List<double>(samples.Count);
|
||||
var gpu = new List<double>(samples.Count);
|
||||
var cpu = new List<double>(samples.Count);
|
||||
var csv = new StringBuilder("wallMs,cpuMs,gpuMs,mainMs,renderMs,presentWaitMs\n");
|
||||
double total = 0;
|
||||
foreach (var s in samples)
|
||||
{
|
||||
wall.Add(s.wallMs); total += s.wallMs;
|
||||
if (s.gpuMs > 0) gpu.Add(s.gpuMs);
|
||||
if (s.cpuMs > 0) cpu.Add(s.cpuMs);
|
||||
csv.AppendFormat(CultureInfo.InvariantCulture, "{0:F5},{1:F5},{2:F5},{3:F5},{4:F5},{5:F5}\n", s.wallMs, s.cpuMs, s.gpuMs, s.mainMs, s.renderMs, s.presentWaitMs);
|
||||
}
|
||||
wall.Sort(); gpu.Sort(); cpu.Sort();
|
||||
File.WriteAllText(Path.Combine(folder, name + ".csv"), csv.ToString());
|
||||
report.segments.Add(new Segment { name = name, frames = samples.Count, cameraRenderFrames = renderedFrames, meanFps = samples.Count * 1000.0 / total, p50Ms = Percentile(wall, .5), p95Ms = Percentile(wall, .95), p99Ms = Percentile(wall, .99), gpuP95Ms = Percentile(gpu, .95), cpuP95Ms = Percentile(cpu, .95), gpuSamples = gpu.Count, cpuSamples = cpu.Count, allocatedMiB = Profiler.GetTotalAllocatedMemoryLong() / 1048576.0, enemyCount = FindObjectsByType<EnemyUnit>().Length, width = Screen.width, height = Screen.height, waypointsVisited = visitedWaypoints - segmentWaypoints, distanceTravelled = travelled - segmentTravelled, passed = Screen.width == 1920 && Screen.height == 1080 && Percentile(wall, .95) <= 16.7 && visitedWaypoints - segmentWaypoints >= 4 && renderedFrames >= samples.Count * .98 });
|
||||
SaveReport();
|
||||
}
|
||||
|
||||
static double Percentile(List<double> sorted, double fraction) => sorted.Count == 0 ? 0 : sorted[Math.Max(0, (int)Math.Ceiling(sorted.Count * fraction) - 1)];
|
||||
void SaveReport() => File.WriteAllText(Path.Combine(folder, "report.json"), JsonUtility.ToJson(report, true));
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
Camera.onPostRender -= CameraRendered;
|
||||
if (keyboard != null && keyboard.added) InputSystem.RemoveDevice(keyboard);
|
||||
if (mouse != null && mouse.added) InputSystem.RemoveDevice(mouse);
|
||||
}
|
||||
|
||||
[Serializable] struct Sample { public double wallMs, cpuMs, gpuMs, mainMs, renderMs, presentWaitMs; }
|
||||
[Serializable] sealed class Segment
|
||||
{
|
||||
public string name;
|
||||
public int frames, width, height, gpuSamples, cpuSamples, enemyCount, waypointsVisited, cameraRenderFrames;
|
||||
public double meanFps, p50Ms, p95Ms, p99Ms, gpuP95Ms, cpuP95Ms, allocatedMiB, distanceTravelled;
|
||||
public bool passed;
|
||||
}
|
||||
[Serializable] sealed class Report
|
||||
{
|
||||
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;
|
||||
public bool completed, frameTimingEnabled, localLightCastsShadows, visualPassed, dynamicResolution, routeCompleted;
|
||||
public List<Segment> segments = new List<Segment>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 833538f031bef3b448f712042795cb10
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
|
||||
namespace Ironspore
|
||||
{
|
||||
@@ -36,6 +37,9 @@ namespace Ironspore
|
||||
Camera view;
|
||||
Transform visual;
|
||||
GameObject cockpit;
|
||||
Renderer[] worldRenderers;
|
||||
ShadowCastingMode[] worldShadowModes;
|
||||
bool displayedFirstPerson;
|
||||
float yaw, pitch, vertical, boostRemaining, boostCooldown, shotCooldown, sideCooldown;
|
||||
Vector3 boostDirection;
|
||||
bool freshTrigger = true;
|
||||
@@ -58,7 +62,12 @@ namespace Ironspore
|
||||
visual.localPosition = Vector3.zero;
|
||||
foreach (var t in visual.GetComponentsInChildren<Transform>())
|
||||
t.gameObject.layer = 8;
|
||||
worldRenderers = visual.GetComponentsInChildren<Renderer>(true);
|
||||
worldShadowModes = new ShadowCastingMode[worldRenderers.Length];
|
||||
for (int i = 0; i < worldRenderers.Length; i++)
|
||||
worldShadowModes[i] = worldRenderers[i].shadowCastingMode;
|
||||
var cam = new GameObject("PilotCamera");
|
||||
cam.tag = "MainCamera";
|
||||
cam.transform.SetParent(transform);
|
||||
view = cam.AddComponent<Camera>();
|
||||
view.nearClipPlane = .08f;
|
||||
@@ -76,7 +85,7 @@ namespace Ironspore
|
||||
if (Primary != primary)
|
||||
{
|
||||
Primary = primary;
|
||||
Rounds = primary.magazineSize;
|
||||
Rounds = primary ? primary.magazineSize : 0;
|
||||
ReloadRemaining = 0;
|
||||
}
|
||||
visual.localScale = game.chassis[game.Run.chassis].modelScale;
|
||||
@@ -87,7 +96,7 @@ 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;
|
||||
@@ -112,12 +121,12 @@ namespace Ironspore
|
||||
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();
|
||||
@@ -136,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)
|
||||
{
|
||||
@@ -159,15 +168,15 @@ namespace Ironspore
|
||||
{
|
||||
if (!game.Action("Fire").IsPressed())
|
||||
freshTrigger = false;
|
||||
if (game.Action("Reload").WasPressedThisFrame() && Rounds < Primary.magazineSize && ReloadRemaining == 0)
|
||||
if (Primary && game.Action("Reload").WasPressedThisFrame() && Rounds < Primary.magazineSize && ReloadRemaining == 0)
|
||||
ReloadRemaining = Primary.reloadSeconds;
|
||||
if (!freshTrigger && game.Action("Fire").IsPressed() && shotCooldown <= 0 && ReloadRemaining == 0 && Rounds > 0)
|
||||
if (Primary && !freshTrigger && game.Action("Fire").IsPressed() && shotCooldown <= 0 && ReloadRemaining == 0 && Rounds > 0)
|
||||
{
|
||||
Shoot(false);
|
||||
if (--Rounds == 0)
|
||||
ReloadRemaining = Primary.reloadSeconds;
|
||||
}
|
||||
if (game.Action("Secondary").WasPressedThisFrame() && sideCooldown <= 0 && Energy >= 15)
|
||||
if (game.Action("Secondary").WasPressedThisFrame() && game.Run.equipment[1] >= 0 && sideCooldown <= 0 && Energy >= 15)
|
||||
{
|
||||
Energy -= 15;
|
||||
Shoot(true);
|
||||
@@ -191,12 +200,19 @@ namespace Ironspore
|
||||
view.transform.SetPositionAndRotation(desired, rotation);
|
||||
float fov = game.MenuOpen ? 70 : game.Action("Aim").IsPressed() ? 50 : FirstPerson ? 78 : 70;
|
||||
view.fieldOfView = Mathf.Lerp(view.fieldOfView, fov, Time.unscaledDeltaTime * 12);
|
||||
visual.gameObject.SetActive(!FirstPerson);
|
||||
cockpit.SetActive(FirstPerson);
|
||||
if (displayedFirstPerson != FirstPerson)
|
||||
{
|
||||
displayedFirstPerson = FirstPerson;
|
||||
for (int i = 0; i < worldRenderers.Length; i++)
|
||||
if (worldRenderers[i])
|
||||
worldRenderers[i].shadowCastingMode = FirstPerson ? ShadowCastingMode.ShadowsOnly : worldShadowModes[i];
|
||||
cockpit.SetActive(FirstPerson);
|
||||
}
|
||||
}
|
||||
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);
|
||||
@@ -227,6 +243,8 @@ namespace Ironspore
|
||||
enemy.Damage(part.damage * (1 + game.Run.upgrades * .15f));
|
||||
lastHitTime = Time.time;
|
||||
}
|
||||
else if (game.Trial && hit.collider.GetComponentInParent<RangeTarget>() is RangeTarget plate && plate.Hit())
|
||||
lastHitTime = Time.time;
|
||||
}
|
||||
WorldFactory.Tracer(muzzle, target, secondary ? new Color(1, .65f, .2f) : new Color(.2f, 1, .85f));
|
||||
GameAudio.Cue(secondary ? 140 : 240, .06f, .07f);
|
||||
@@ -237,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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f503296d8ae461f41a8229186bbad63e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
|
||||
namespace Ironspore
|
||||
{
|
||||
public sealed class WorldLighting : MonoBehaviour
|
||||
{
|
||||
readonly List<Light> fixtures = new List<Light>(2);
|
||||
WorldLightingProfile profile;
|
||||
Material sky;
|
||||
ReflectionProbe environment;
|
||||
Camera observer;
|
||||
bool ownsProfile;
|
||||
int shadowBudget;
|
||||
int qualityLevel = -1;
|
||||
Light sun;
|
||||
bool captureQueued, capturing;
|
||||
float captureAfter;
|
||||
public bool ReflectionReady { get; private set; }
|
||||
|
||||
public void Initialize(WorldLightingProfile settings, bool hangar)
|
||||
{
|
||||
profile = settings;
|
||||
if (!profile)
|
||||
{
|
||||
profile = ScriptableObject.CreateInstance<WorldLightingProfile>();
|
||||
ownsProfile = true;
|
||||
}
|
||||
RenderSettings.ambientMode = AmbientMode.Trilight;
|
||||
RenderSettings.ambientSkyColor = profile.ambientSky;
|
||||
RenderSettings.ambientEquatorColor = profile.ambientEquator;
|
||||
RenderSettings.ambientGroundColor = profile.ambientGround;
|
||||
RenderSettings.fog = !hangar;
|
||||
RenderSettings.fogMode = FogMode.Linear;
|
||||
RenderSettings.fogColor = profile.fogColor;
|
||||
RenderSettings.fogStartDistance = profile.fogStart;
|
||||
RenderSettings.fogEndDistance = profile.fogEnd;
|
||||
RenderSettings.reflectionIntensity = profile.reflectionIntensity;
|
||||
var sourceSky = RenderSettings.skybox;
|
||||
sky = sourceSky ? new Material(sourceSky) : new Material(Shader.Find("Skybox/Procedural"));
|
||||
sky.name = "RuntimeDaySky";
|
||||
if (sky.HasProperty("_Exposure")) sky.SetFloat("_Exposure", profile.skyExposure);
|
||||
RenderSettings.skybox = sky;
|
||||
var sunObject = new GameObject("Sun");
|
||||
sunObject.transform.SetParent(transform, false);
|
||||
sunObject.transform.rotation = Quaternion.Euler(profile.sunEuler);
|
||||
sun = sunObject.AddComponent<Light>();
|
||||
sun.type = LightType.Directional;
|
||||
sun.color = profile.sunColor;
|
||||
sun.intensity = hangar ? profile.hangarSunIntensity : profile.sunIntensity;
|
||||
sun.renderMode = LightRenderMode.ForcePixel;
|
||||
sun.shadowStrength = 1;
|
||||
sun.shadowBias = profile.bias;
|
||||
sun.shadowNormalBias = profile.normalBias;
|
||||
sun.shadowNearPlane = profile.shadowNearPlane;
|
||||
RenderSettings.sun = sun;
|
||||
ApplyQuality();
|
||||
}
|
||||
|
||||
public void CompleteEnvironment(Camera camera, bool hangar)
|
||||
{
|
||||
observer = camera;
|
||||
if (hangar)
|
||||
{
|
||||
WorldFactory.Box("HangarWorkFixture", new Vector3(2, 8.2f, 1), new Vector3(2, .18f, .65f), new Color(.8f, .87f, 1), transform, false, true);
|
||||
AddSpot("HangarWorkLight", new Vector3(2, 8, 1), new Vector3(2, 1, 2), new Color(.82f, .9f, 1), profile.hangarIntensity, 17, 84);
|
||||
}
|
||||
else
|
||||
{
|
||||
WorldFactory.Box("NavigationWorkFixture", new Vector3(0, 3.4f, 22.7f), new Vector3(1.2f, .16f, .4f), new Color(.55f, 1, .85f), transform, false, true);
|
||||
AddSpot("NavigationWorkLight", new Vector3(0, 3.25f, 22.3f), new Vector3(0, 0, 20.6f), new Color(.65f, 1, .85f), profile.navigationIntensity, 11, 78);
|
||||
WorldFactory.Box("ExtractionLampPost", new Vector3(-22.4f, 3.1f, -8), new Vector3(.16f, 6.2f, .16f), new Color(.2f, .25f, .28f), transform, false);
|
||||
WorldFactory.Box("ExtractionLampArm", new Vector3(-20.7f, 6.1f, -8), new Vector3(3.5f, .15f, .18f), new Color(.2f, .25f, .28f), transform, false);
|
||||
WorldFactory.Box("ExtractionWorkFixture", new Vector3(-19, 6, -8), new Vector3(1, .18f, .6f), new Color(1, .77f, .45f), transform, false, true);
|
||||
AddSpot("ExtractionWorkLight", new Vector3(-19, 5.8f, -8), new Vector3(-19, 0, -8), new Color(1, .8f, .55f), profile.extractionIntensity, 13, 76);
|
||||
}
|
||||
var probeObject = new GameObject("EnvironmentReflection");
|
||||
probeObject.transform.SetParent(transform, false);
|
||||
probeObject.transform.position = hangar ? new Vector3(2, 3, 2) : new Vector3(0, 3, 8);
|
||||
environment = probeObject.AddComponent<ReflectionProbe>();
|
||||
environment.mode = ReflectionProbeMode.Realtime;
|
||||
environment.refreshMode = ReflectionProbeRefreshMode.ViaScripting;
|
||||
environment.timeSlicingMode = ReflectionProbeTimeSlicingMode.IndividualFaces;
|
||||
environment.resolution = QualitySettings.GetQualityLevel() >= 4 ? 256 : 128;
|
||||
environment.hdr = true;
|
||||
environment.boxProjection = true;
|
||||
environment.blendDistance = 5;
|
||||
environment.size = hangar ? new Vector3(66, 18, 45) : new Vector3(74, 20, 104);
|
||||
// The captured environment must not freeze actors, transient props or world labels.
|
||||
environment.cullingMask = ~((1 << 8) | (1 << 9) | (1 << 10) | (1 << 11));
|
||||
environment.nearClipPlane = .2f;
|
||||
environment.farClipPlane = 150;
|
||||
environment.intensity = profile.reflectionIntensity;
|
||||
SelectLights();
|
||||
StartCoroutine(CaptureEnvironment());
|
||||
}
|
||||
|
||||
void AddSpot(string name, Vector3 position, Vector3 target, Color color, float intensity, float range, float angle)
|
||||
{
|
||||
var obj = new GameObject(name);
|
||||
obj.transform.SetParent(transform, false);
|
||||
obj.transform.position = position;
|
||||
obj.transform.LookAt(target);
|
||||
var light = obj.AddComponent<Light>();
|
||||
light.type = LightType.Spot;
|
||||
light.renderMode = LightRenderMode.ForcePixel;
|
||||
light.color = color;
|
||||
light.intensity = intensity;
|
||||
light.range = range;
|
||||
light.spotAngle = angle;
|
||||
light.innerSpotAngle = angle * .65f;
|
||||
light.shadows = LightShadows.Soft;
|
||||
light.shadowCustomResolution = 1024;
|
||||
light.shadowBias = profile.bias;
|
||||
light.shadowNormalBias = profile.normalBias;
|
||||
light.shadowNearPlane = profile.shadowNearPlane;
|
||||
light.enabled = false;
|
||||
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());
|
||||
}
|
||||
|
||||
void SelectLights()
|
||||
{
|
||||
if (!observer)
|
||||
return;
|
||||
Vector3 position = observer.transform.position;
|
||||
float limit = profile.localLightCullDistance * profile.localLightCullDistance;
|
||||
Light nearest = null;
|
||||
float distance = limit;
|
||||
foreach (var light in fixtures)
|
||||
{
|
||||
float candidate = (light.transform.position - position).sqrMagnitude;
|
||||
if (candidate < distance) { distance = candidate; nearest = light; }
|
||||
}
|
||||
foreach (var light in fixtures)
|
||||
{
|
||||
bool active = shadowBudget > 0 && (light == nearest || shadowBudget > 1 && (light.transform.position - position).sqrMagnitude < limit);
|
||||
if (light.enabled == active)
|
||||
continue;
|
||||
light.enabled = active;
|
||||
captureQueued = true;
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerator CaptureEnvironment()
|
||||
{
|
||||
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))
|
||||
yield return null;
|
||||
ReflectionReady = environment != null;
|
||||
capturing = false;
|
||||
captureAfter = Time.unscaledTime + 8;
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (sky)
|
||||
{
|
||||
if (RenderSettings.skybox == sky) RenderSettings.skybox = null;
|
||||
Destroy(sky);
|
||||
}
|
||||
if (ownsProfile && profile) Destroy(profile);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9f71c474a8bca074aa2274b7a4054680
|
||||
@@ -0,0 +1,31 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ironspore
|
||||
{
|
||||
[CreateAssetMenu(menuName = "Ironspore/World Lighting Profile")]
|
||||
public sealed class WorldLightingProfile : ScriptableObject
|
||||
{
|
||||
[Header("Daylight")]
|
||||
public Vector3 sunEuler = new Vector3(48, -35, 0);
|
||||
public Color sunColor = new Color(1, .94f, .84f);
|
||||
[Min(0)] public float sunIntensity = 1.2f;
|
||||
[Min(0)] public float hangarSunIntensity = .8f;
|
||||
public Color ambientSky = new Color(.24f, .30f, .38f);
|
||||
public Color ambientEquator = new Color(.12f, .16f, .19f);
|
||||
public Color ambientGround = new Color(.045f, .055f, .065f);
|
||||
public Color fogColor = new Color(.15f, .21f, .26f);
|
||||
public float fogStart = 38;
|
||||
public float fogEnd = 145;
|
||||
[Range(0, 2)] public float skyExposure = .85f;
|
||||
[Range(0, 1)] public float reflectionIntensity = .7f;
|
||||
[Header("Shadows")]
|
||||
[Range(0, 2)] public float bias = .08f;
|
||||
[Range(0, 2)] public float normalBias = .4f;
|
||||
[Min(.01f)] public float shadowNearPlane = .2f;
|
||||
[Header("Industrial fixtures")]
|
||||
[Min(0)] public float navigationIntensity = 6;
|
||||
[Min(0)] public float extractionIntensity = 6;
|
||||
[Min(0)] public float hangarIntensity = 5;
|
||||
[Min(1)] public float localLightCullDistance = 50;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 90562a900c2114d48bd80969332d5ec6
|
||||
@@ -19,6 +19,7 @@ namespace Ironspore
|
||||
public UnityEngine.UIElements.StyleSheet uiStyle;
|
||||
public UnityEngine.UIElements.PanelSettings uiPanel;
|
||||
public LocalizationCatalog localization;
|
||||
public WorldLightingProfile lightingProfile;
|
||||
public bool startInRange;
|
||||
public ProfileStore Store
|
||||
{
|
||||
@@ -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() ?? "";
|
||||
@@ -76,8 +78,8 @@ namespace Ironspore
|
||||
{
|
||||
Active = this;
|
||||
GameLocalization.Initialize(localization);
|
||||
Application.targetFrameRate = 120;
|
||||
mainStore = new ProfileStore(Path.Combine(GameDataPaths.DirectoryPath, "profile.json"));
|
||||
Application.targetFrameRate = 60;
|
||||
mainStore = new ProfileStore(ProfileStore.SavePath(false));
|
||||
Store = mainStore;
|
||||
Input = Instantiate(inputTemplate);
|
||||
var defaults = SettingsService.RuntimeDefaults();
|
||||
@@ -93,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)
|
||||
@@ -109,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;
|
||||
}
|
||||
@@ -116,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()
|
||||
@@ -141,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;
|
||||
@@ -150,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(GameDataPaths.DirectoryPath, "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()
|
||||
{
|
||||
@@ -215,10 +230,15 @@ namespace Ironspore
|
||||
{
|
||||
if (Time.unscaledTime > noticeUntil)
|
||||
noticeMessage = null;
|
||||
if (Hud && (Hud.SettingsOpen || Hud.MenuInputConsumedThisFrame))
|
||||
return;
|
||||
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)
|
||||
@@ -247,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)
|
||||
{
|
||||
@@ -289,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()
|
||||
@@ -304,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;
|
||||
@@ -316,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()
|
||||
{
|
||||
@@ -406,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;
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ironspore
|
||||
{
|
||||
public sealed class TrainingCourse : MonoBehaviour
|
||||
{
|
||||
static readonly Color Steel = new Color(.19f, .28f, .32f);
|
||||
static readonly Color Amber = new Color(.95f, .48f, .12f);
|
||||
static readonly Color Green = new Color(.16f, .92f, .65f);
|
||||
GameSession game;
|
||||
Renderer firstLamp, upperLamp;
|
||||
RangeTarget plate, movingPlate;
|
||||
Vector3 previousPosition;
|
||||
int gateCount, hits;
|
||||
float startedAt, finishSeconds;
|
||||
static readonly Vector3 StartGate = new Vector3(19, 0, -15);
|
||||
static readonly Vector3 UpperGate = new Vector3(19, 4.3f, 4);
|
||||
static readonly Vector3 Landing = new Vector3(19, 4.3f, 22);
|
||||
|
||||
public string Instruction
|
||||
{
|
||||
get
|
||||
{
|
||||
Vector3 position = game.World.Player.transform.position;
|
||||
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", 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");
|
||||
if (gateCount == 1) return GameLocalization.Text("range.gate");
|
||||
return GameLocalization.Text("range.choose");
|
||||
}
|
||||
}
|
||||
|
||||
public void Build(GameSession session)
|
||||
{
|
||||
game = session;
|
||||
previousPosition = game.World.Player.transform.position;
|
||||
firstLamp = Gate(StartGate);
|
||||
upperLamp = Gate(UpperGate);
|
||||
WorldFactory.Label("range.start", StartGate + Vector3.up * 5.95f, Green, transform);
|
||||
WorldFactory.Label("range.upper", UpperGate + Vector3.up * 5.95f, Green, transform);
|
||||
WorldFactory.Label("range.landing", Landing + Vector3.up * 1.7f, Green, transform);
|
||||
for (int i = -1; i <= 1; i++)
|
||||
WorldFactory.Box("LandingStripe", Landing + new Vector3(0,.02f,i * 1.35f), new Vector3(4.7f,.035f,.17f), Amber, transform, false);
|
||||
plate = Target(new Vector3(-13, 0, -2), false);
|
||||
movingPlate = Target(new Vector3(-25, 0, -2), true);
|
||||
WorldFactory.Box("TraverseRail", new Vector3(-25,.12f,-2), new Vector3(9,.24f,.8f), Steel, transform);
|
||||
foreach (int side in new[] { -1, 1 })
|
||||
WorldFactory.Box("RailStop", new Vector3(-25 + side * 4.5f,.4f,-2), new Vector3(.35f,.8f,1.3f), Amber, transform);
|
||||
WorldFactory.Box("FiringLine", new Vector3(-19,.025f,-15), new Vector3(12,.04f,.15f), Amber, transform, false);
|
||||
WorldFactory.Label("range.gallery", new Vector3(-19,4.6f,-2), Amber, transform);
|
||||
WorldFactory.Label("range.salvage", new Vector3(-24,4,14), Green, transform);
|
||||
var desk = WorldFactory.Box("RangeResetDesk", new Vector3(-12, .85f, -15), new Vector3(1.4f, 1.7f, .85f), Steel, transform);
|
||||
WorldFactory.Box("ResetSwitch", desk.transform.position + Vector3.up * .92f, new Vector3(.8f,.14f,.5f), Green, transform, false, true);
|
||||
var reset = desk.AddComponent<RangeReset>();
|
||||
reset.kind = 4;
|
||||
reset.course = this;
|
||||
WorldFactory.Label("range.resetSign", desk.transform.position + Vector3.up * 1.6f, Green, transform);
|
||||
}
|
||||
|
||||
Renderer Gate(Vector3 at)
|
||||
{
|
||||
foreach (int side in new[] { -1, 1 })
|
||||
{
|
||||
Vector3 foot = at + Vector3.right * side * 3.1f;
|
||||
WorldFactory.Box("GateFoot", foot + Vector3.up * .14f, new Vector3(.8f,.28f,1.2f), Steel, transform);
|
||||
WorldFactory.Box("GatePost", foot + Vector3.up * 2.65f, new Vector3(.35f,5.3f,.5f), Steel, transform);
|
||||
WorldFactory.Box("GateStripe", foot + Vector3.up * 2, new Vector3(.38f,.8f,.53f), Amber, transform, false);
|
||||
}
|
||||
return WorldFactory.Box("GateLamp", at + Vector3.up * 5.35f, new Vector3(6.6f,.26f,.45f), Amber, transform, false, true).GetComponent<Renderer>();
|
||||
}
|
||||
|
||||
RangeTarget Target(Vector3 at, bool moves)
|
||||
{
|
||||
var carriage = new GameObject(moves ? "TraversingTarget" : "FixedTarget").transform;
|
||||
carriage.SetParent(transform, false);
|
||||
carriage.position = at;
|
||||
var stand = WorldFactory.Box("TargetStand", at + Vector3.up * .55f, new Vector3(2.2f,1.1f,.85f), Steel, carriage);
|
||||
if (moves) stand.layer = 10;
|
||||
var pivot = new GameObject("RangeTarget").transform;
|
||||
pivot.SetParent(carriage, false);
|
||||
pivot.position = at + Vector3.up * 1.15f;
|
||||
var panel = WorldFactory.Box("StrikePlate", pivot.position + Vector3.up * 1.15f, new Vector3(2.1f,2.3f,.22f), Amber, pivot);
|
||||
var bull = WorldFactory.Primitive(PrimitiveType.Cylinder, "Bullseye", panel.transform.position + Vector3.back * .14f, new Vector3(1.25f,.025f,1.25f), Steel, pivot, false, false);
|
||||
bull.transform.rotation = Quaternion.Euler(90,0,0);
|
||||
var lamp = WorldFactory.Box("TargetLamp", bull.transform.position + Vector3.back * .04f, new Vector3(.52f,.52f,.05f), Amber, pivot, false, true);
|
||||
foreach (Transform part in pivot.GetComponentsInChildren<Transform>()) part.gameObject.layer = 10;
|
||||
var target = pivot.gameObject.AddComponent<RangeTarget>();
|
||||
target.Initialize(this, panel.GetComponent<Collider>(), lamp.GetComponent<Renderer>(), moves);
|
||||
return target;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (!game.Pilot || game.Paused) return;
|
||||
Vector3 position = game.Pilot.transform.position;
|
||||
if ((gateCount == 0 || gateCount == 3) && Crossed(previousPosition, position, StartGate))
|
||||
{
|
||||
gateCount = 1;
|
||||
startedAt = Time.time;
|
||||
SetLamp(firstLamp, true);
|
||||
SetLamp(upperLamp, false);
|
||||
game.Notify("range.gate");
|
||||
}
|
||||
else if (gateCount == 1 && Crossed(previousPosition, position, UpperGate))
|
||||
{
|
||||
gateCount = 2;
|
||||
SetLamp(upperLamp, true);
|
||||
game.Notify("range.brake");
|
||||
}
|
||||
else if (gateCount == 2 && Mathf.Abs(position.x - Landing.x) < 2.65f &&
|
||||
Mathf.Abs(position.z - Landing.z) < 2.4f && Mathf.Abs(position.y - Landing.y) < .6f &&
|
||||
game.Pilot.Grounded && game.Pilot.HorizontalSpeed < 1)
|
||||
{
|
||||
gateCount = 3;
|
||||
finishSeconds = Time.time - startedAt;
|
||||
game.Notify("range.finish", finishSeconds);
|
||||
GameAudio.Cue(620,.2f);
|
||||
}
|
||||
previousPosition = position;
|
||||
}
|
||||
|
||||
static bool Crossed(Vector3 from, Vector3 to, Vector3 gate)
|
||||
{
|
||||
if (from.z >= gate.z || to.z < gate.z) return false;
|
||||
Vector3 crossing = Vector3.Lerp(from, to, (gate.z - from.z) / (to.z - from.z));
|
||||
return Mathf.Abs(crossing.x - gate.x) < 2.9f && crossing.y >= gate.y - .5f && crossing.y < gate.y + 5;
|
||||
}
|
||||
|
||||
internal static void SetLamp(Renderer renderer, bool on)
|
||||
{
|
||||
renderer.sharedMaterial = WorldFactory.Material(on ? Green : Amber, true);
|
||||
}
|
||||
|
||||
internal void Hit()
|
||||
{
|
||||
game.Notify("range.hit", ++hits);
|
||||
GameAudio.Cue(880, .12f);
|
||||
}
|
||||
|
||||
public void ResetPractice()
|
||||
{
|
||||
hits = gateCount = 0;
|
||||
previousPosition = game.Pilot.transform.position;
|
||||
SetLamp(firstLamp, false);
|
||||
SetLamp(upperLamp, false);
|
||||
plate.ResetTarget();
|
||||
movingPlate.ResetTarget();
|
||||
game.Notify("range.ready");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RangeTarget : MonoBehaviour
|
||||
{
|
||||
TrainingCourse course;
|
||||
Collider strike;
|
||||
Renderer lamp;
|
||||
bool down, moving;
|
||||
float travelTime, holdTime;
|
||||
Vector3 origin;
|
||||
|
||||
internal void Initialize(TrainingCourse owner, Collider collider, Renderer indicator, bool moves)
|
||||
{
|
||||
course = owner;
|
||||
strike = collider;
|
||||
lamp = indicator;
|
||||
moving = moves;
|
||||
origin = transform.parent.localPosition;
|
||||
}
|
||||
|
||||
public bool Hit()
|
||||
{
|
||||
if (down || holdTime > 0) return false;
|
||||
down = !moving;
|
||||
if (moving) holdTime = 1f;
|
||||
else strike.enabled = false;
|
||||
TrainingCourse.SetLamp(lamp, true);
|
||||
course.Hit();
|
||||
return true;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (down) transform.localRotation = Quaternion.RotateTowards(transform.localRotation, Quaternion.Euler(87,0,0), 240 * Time.deltaTime);
|
||||
if (!moving) return;
|
||||
if (holdTime > 0)
|
||||
{
|
||||
holdTime -= Time.deltaTime;
|
||||
if (holdTime <= 0) TrainingCourse.SetLamp(lamp, false);
|
||||
return;
|
||||
}
|
||||
travelTime += Time.deltaTime;
|
||||
transform.parent.localPosition = origin + Vector3.right * (3.1f * Mathf.Sin(travelTime * .85f));
|
||||
}
|
||||
|
||||
internal void ResetTarget()
|
||||
{
|
||||
down = false;
|
||||
holdTime = travelTime = 0;
|
||||
transform.parent.localPosition = origin;
|
||||
strike.enabled = true;
|
||||
transform.localRotation = Quaternion.identity;
|
||||
TrainingCourse.SetLamp(lamp, false);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RangeReset : WorldInteractable
|
||||
{
|
||||
public TrainingCourse course;
|
||||
public override string Prompt => GameLocalization.Text("range.reset", GameSession.Active.Settings.Bindings.Display("Interact"));
|
||||
public override void Activate() => course.ResetPractice();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c0607d512ea805f459c7d25872379957
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ironspore
|
||||
{
|
||||
public sealed class TrainingCrate : WorldInteractable
|
||||
{
|
||||
Transform hinge;
|
||||
Transform latch;
|
||||
const float UnlatchSeconds = .16f;
|
||||
const float LiftSeconds = .55f;
|
||||
bool started;
|
||||
public void Build(Transform world, Vector3 basePosition, bool part, bool showLabel)
|
||||
{
|
||||
Color accent = part ? new Color(.98f,.53f,.17f) : new Color(.16f,.85f,.67f);
|
||||
hinge = new GameObject("CaseHinge").transform;
|
||||
hinge.SetParent(world, false);
|
||||
hinge.position = basePosition + new Vector3(0,1.36f,.71f);
|
||||
var lid = WorldFactory.Box("CaseLid", hinge.position + Vector3.back * .72f, new Vector3(1.96f,.16f,1.49f), accent, hinge, false);
|
||||
lid.layer = 10;
|
||||
foreach (int side in new[] { -1, 1 })
|
||||
WorldFactory.Box("CaseStrap", lid.transform.position + new Vector3(side * .66f,.1f,0), new Vector3(.14f,.07f,1.42f), Color.gray, hinge, false).layer = 10;
|
||||
latch = new GameObject("CaseLatchPivot").transform;
|
||||
latch.SetParent(hinge, false);
|
||||
latch.localPosition = new Vector3(0, 0, -1.49f);
|
||||
WorldFactory.Box("CaseLatch", latch.position + Vector3.down * .13f, new Vector3(.38f, .26f, .09f), Color.gray, latch, false).layer = 10;
|
||||
WorldFactory.Box("CaseLiner", basePosition + Vector3.up * 1.30f, new Vector3(1.58f, .02f, 1.18f), new Color(.07f, .10f, .11f), world, false).layer = 10;
|
||||
if (showLabel)
|
||||
WorldFactory.Label(part ? "range.parts" : "range.material", basePosition + Vector3.up * 2.4f, accent, world);
|
||||
}
|
||||
|
||||
public override void RefreshLabel()
|
||||
{
|
||||
if (collected && !started)
|
||||
{
|
||||
started = true;
|
||||
StartCoroutine(OpenLid());
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerator OpenLid()
|
||||
{
|
||||
float elapsed = 0;
|
||||
while (elapsed < UnlatchSeconds + LiftSeconds)
|
||||
{
|
||||
yield return null;
|
||||
elapsed += Time.deltaTime;
|
||||
latch.localRotation = Quaternion.Euler(Mathf.SmoothStep(0, 78, elapsed / UnlatchSeconds), 0, 0);
|
||||
hinge.localRotation = Quaternion.Euler(Mathf.SmoothStep(0, 92, (elapsed - UnlatchSeconds) / LiftSeconds), 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 165d2c6b7407be447abcb69a251294c5
|
||||
@@ -30,10 +30,12 @@ namespace Ironspore
|
||||
public System.Collections.Generic.IReadOnlyList<Rect> Cover => cover;
|
||||
Transform root;
|
||||
GameSession game;
|
||||
WorldLighting lighting;
|
||||
public TrainingCourse Course { get; private set; }
|
||||
Vector3 extractionPosition = new Vector3(-19, 0, -8);
|
||||
GameObject transport;
|
||||
|
||||
void ResetWorld()
|
||||
void ResetWorld(bool hangar)
|
||||
{
|
||||
game = GetComponent<GameSession>();
|
||||
if (root)
|
||||
@@ -45,21 +47,12 @@ namespace Ironspore
|
||||
ExtractionRemaining = -1;
|
||||
TransportReady = false;
|
||||
Focused = null;
|
||||
Course = null;
|
||||
Escort = null;
|
||||
relay = onwardGate = terminalTarget = null;
|
||||
cover.Clear();
|
||||
RenderSettings.ambientLight = new Color(.27f, .34f, .38f);
|
||||
RenderSettings.fog = true;
|
||||
RenderSettings.fogColor = new Color(.09f, .14f, .18f);
|
||||
RenderSettings.fogDensity = .009f;
|
||||
var sun = new GameObject("Sun");
|
||||
sun.transform.SetParent(root);
|
||||
sun.transform.rotation = Quaternion.Euler(48, -35, 0);
|
||||
var light = sun.AddComponent<Light>();
|
||||
light.type = LightType.Directional;
|
||||
light.intensity = 1.7f;
|
||||
light.color = new Color(1, .87f, .72f);
|
||||
light.shadows = LightShadows.Soft;
|
||||
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);
|
||||
for (int x = -30; x <= 30; x += 5)
|
||||
WorldFactory.Box("FloorSeam", new Vector3(x, .008f, 8), new Vector3(.035f, .015f, 96), new Color(.18f, .24f, .26f), root, false);
|
||||
@@ -77,7 +70,7 @@ namespace Ironspore
|
||||
}
|
||||
public void BuildHangar()
|
||||
{
|
||||
ResetWorld();
|
||||
ResetWorld(true);
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var model = Instantiate(game.mechModel, root);
|
||||
@@ -88,16 +81,18 @@ namespace Ironspore
|
||||
WorldFactory.Cylinder("DisplayPad", new Vector3(i * 4 - 2, .05f, 2), new Vector3(3, .1f, 3), game.chassis[i].accent, root, false);
|
||||
}
|
||||
var camera = new GameObject("HangarCamera");
|
||||
camera.tag = "MainCamera";
|
||||
camera.transform.SetParent(root);
|
||||
camera.AddComponent<Camera>().fieldOfView = 47;
|
||||
camera.transform.position = new Vector3(11, 4.3f, -11);
|
||||
camera.transform.LookAt(new Vector3(2, 1.5f, 2));
|
||||
camera.AddComponent<AudioListener>();
|
||||
WorldFactory.Box("RoofBeam", new Vector3(0, 9, 4), new Vector3(62, 1.3f, 2), new Color(.16f, .22f, .24f), root);
|
||||
lighting.CompleteEnvironment(camera.GetComponent<Camera>(), true);
|
||||
}
|
||||
public void BuildWarzone()
|
||||
{
|
||||
ResetWorld();
|
||||
ResetWorld(false);
|
||||
var run = game.Run;
|
||||
var avatar = new GameObject("PlayerMech");
|
||||
avatar.transform.SetParent(root);
|
||||
@@ -121,13 +116,14 @@ namespace Ironspore
|
||||
for (int i = 0; i < 6; i++)
|
||||
{
|
||||
Vector3 pos = new Vector3(i % 2 == 0 ? -8 : 9, 0, -6 + i * 6);
|
||||
bool demo = game.Trial && (i == 1 || i == 2);
|
||||
if (demo) pos = new Vector3(-24, 0, i == 1 ? 9 : 18);
|
||||
string key = "zone" + run.stage + "_crate" + i;
|
||||
if (run.searched.Contains(key))
|
||||
continue;
|
||||
string item = i == 0 ? "blueprint_boost_efficient" : i == 3 ? "blueprint_coil_capacitor" : i == 5 ? "blueprint_reactive_guard" : (i + run.biome) % 2 == 0 ? "alloy" : "cells";
|
||||
var crate = WorldFactory.Box("SalvageCrate", pos + Vector3.up * .65f, new Vector3(1.8f, 1.3f, 1.4f), new Color(.24f, .42f, .39f), root);
|
||||
WorldFactory.Box("CrateLatch", pos + new Vector3(0, 1.32f, 0), new Vector3(1.4f, .08f, .85f), new Color(.15f, .95f, .73f), root, false, true);
|
||||
var interaction = crate.AddComponent<WorldInteractable>();
|
||||
var interaction = crate.AddComponent<TrainingCrate>();
|
||||
interaction.kind = 0;
|
||||
interaction.key = key;
|
||||
interaction.itemId = item;
|
||||
@@ -135,6 +131,7 @@ namespace Ironspore
|
||||
interaction.offersPart = i == 2 || i == 4;
|
||||
interaction.partSlot = i == 2 ? 2 : 0;
|
||||
interaction.partIndex = run.stage % 2 == 0 ? 1 : 2;
|
||||
interaction.Build(root, pos, interaction.offersPart, demo);
|
||||
interaction.RefreshLabel();
|
||||
}
|
||||
var objective = WorldFactory.Box("NavigationRelay", new Vector3(0, 1.5f, 23), new Vector3(2.2f, 3, 1.6f), new Color(.21f, .33f, .36f), root);
|
||||
@@ -161,7 +158,7 @@ namespace Ironspore
|
||||
WorldFactory.Label("world.relay", new Vector3(0, 5, 23), new Color(.25f, 1, .85f), root);
|
||||
int count = game.Trial ? 2 : 3 + run.stage;
|
||||
for (int i = 0; i < count; i++)
|
||||
SpawnEnemy(new Vector3((i % 2 == 0 ? -1 : 1) * (4 + i % 3 * 3), .1f, 6 + i * 5), i % 3, i);
|
||||
SpawnEnemy(new Vector3((i % 2 == 0 ? -1 : 1) * (4 + i % 3 * 3), .1f, (game.Trial ? 39 : 6) + i * 5), i % 3, i);
|
||||
if (!game.Trial && run.stage == 4)
|
||||
{
|
||||
objective.SetActive(false);
|
||||
@@ -173,8 +170,15 @@ namespace Ironspore
|
||||
terminalTarget = cart.transform;
|
||||
Escort = cart.AddComponent<EscortUnit>();
|
||||
Escort.Initialize(game);
|
||||
cart.layer = 10;
|
||||
}
|
||||
}
|
||||
if (game.Trial)
|
||||
{
|
||||
Course = root.gameObject.AddComponent<TrainingCourse>();
|
||||
Course.Build(game);
|
||||
}
|
||||
lighting.CompleteEnvironment(Player.GetComponentInChildren<Camera>(), false);
|
||||
}
|
||||
void SpawnEnemy(Vector3 pos, int type, int number)
|
||||
{
|
||||
@@ -213,7 +217,8 @@ namespace Ironspore
|
||||
{
|
||||
TransportReady = true;
|
||||
transport = WorldFactory.Box("RecoveryTransport", extractionPosition + new Vector3(0, 5.5f, 0), new Vector3(7, 1.8f, 10), new Color(.24f, .3f, .32f), root, false);
|
||||
WorldFactory.Box("TransportWing", transport.transform.position, new Vector3(14, .5f, 3), new Color(.32f, .4f, .41f), 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.Settings.Bindings.Display("Interact"));
|
||||
GameAudio.Cue(950, .3f);
|
||||
}
|
||||
@@ -235,18 +240,19 @@ namespace Ironspore
|
||||
public void LaunchPod(Vector3 position)
|
||||
{
|
||||
var pod = WorldFactory.Box("CargoPod", position + Vector3.up * 2, new Vector3(1.4f, 1, 1.4f), new Color(.1f, .8f, .6f), root, false, true);
|
||||
pod.layer = 10;
|
||||
pod.AddComponent<PodFlight>();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WorldInteractable : MonoBehaviour
|
||||
public class WorldInteractable : MonoBehaviour
|
||||
{
|
||||
public int kind;
|
||||
public string key, itemId;
|
||||
public int amount;
|
||||
public bool collected, offersPart;
|
||||
public int partSlot, partIndex;
|
||||
public string Prompt
|
||||
public virtual string Prompt
|
||||
{
|
||||
get
|
||||
{
|
||||
@@ -261,12 +267,12 @@ namespace Ironspore
|
||||
return GameLocalization.Text(g.World.TransportReady ? "prompt.board" : g.World.ExtractionRemaining >= 0 ? "prompt.inbound" : "prompt.call", interact);
|
||||
}
|
||||
}
|
||||
public void RefreshLabel()
|
||||
public virtual void RefreshLabel()
|
||||
{
|
||||
if (collected)
|
||||
transform.localScale = Vector3.one * .5f;
|
||||
}
|
||||
public void Activate()
|
||||
public virtual void Activate()
|
||||
{
|
||||
var g = GameSession.Active;
|
||||
if (kind == 0 && !collected)
|
||||
|
||||
@@ -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
|
||||
@@ -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 =>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
@@ -62,14 +63,15 @@ namespace Ironspore
|
||||
bool redraw = Time.unscaledTime >= nextMapUpdate;
|
||||
if (!redraw && pilot.Rounds == displayedRounds) return;
|
||||
displayedRounds = pilot.Rounds;
|
||||
primary.text = pilot.Primary.LocalizedName;
|
||||
rounds.text = pilot.Rounds.ToString();
|
||||
rounds.EnableInClassList("ammo-low", pilot.Rounds <= pilot.Primary.magazineSize / 4);
|
||||
reload.text = pilot.ReloadRemaining > 0 ? GameLocalization.Text("ui.combat.loading", pilot.ReloadRemaining) : reloadHint;
|
||||
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");
|
||||
secondary.text = game.Part(1, game.Run.equipment[1]).LocalizedName + " · " + state;
|
||||
cargo.text = GameLocalization.Text("ui.combat.cargo", game.Run.cargo.Count);
|
||||
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();
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace Ironspore
|
||||
{
|
||||
public sealed class GameHud : MonoBehaviour
|
||||
public sealed partial class GameHud : MonoBehaviour
|
||||
{
|
||||
GameSession game;
|
||||
VisualElement root;
|
||||
@@ -43,6 +44,9 @@ namespace Ironspore
|
||||
root.styleSheets.Add(game.uiStyle);
|
||||
root.pickingMode = PickingMode.Ignore;
|
||||
root.style.unityFontDefinition = FontDefinition.FromSDFFont(game.localization.uiFont);
|
||||
root.RegisterCallback<PointerMoveEvent>(DragMove, TrickleDown.TrickleDown);
|
||||
root.RegisterCallback<PointerUpEvent>(DragEnd, TrickleDown.TrickleDown);
|
||||
root.RegisterCallback<PointerCancelEvent>(_ => ResetDrag(), TrickleDown.TrickleDown);
|
||||
root.RegisterCallback<KeyDownEvent>(HandleLanguageKey, TrickleDown.TrickleDown);
|
||||
GameLocalization.Changed += RefreshLocale;
|
||||
settingsScreen = new SettingsScreen(game.Settings, () => game.Action("Pause").WasPressedThisFrame());
|
||||
@@ -113,6 +117,7 @@ namespace Ironspore
|
||||
}
|
||||
void Clear()
|
||||
{
|
||||
ResetDrag();
|
||||
settingsScreen?.Close();
|
||||
root.Clear();
|
||||
mission = prompt = status = notice = telemetry = null;
|
||||
@@ -237,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++)
|
||||
@@ -262,12 +267,27 @@ namespace Ironspore
|
||||
var save = game.Store.Data;
|
||||
Text(content, L("ui.home.inventory"), "section");
|
||||
Text(content, L("ui.home.stock", save.alloy, save.cells, save.blueprints.Count, save.extractions, save.completed), "budget");
|
||||
foreach (var item in save.warehouse)
|
||||
{
|
||||
int slot = InventoryGrid.Slot(item.itemId);
|
||||
Button(content, L("ui.home.chooseItem", item.Name, item.uid.Substring(0, Math.Min(6, item.uid.Length))), () =>
|
||||
{
|
||||
game.SelectedInstances[slot] = item.uid;
|
||||
game.InitialEquipment[slot] = InventoryGrid.Index(slot, item.itemId);
|
||||
ShowHangar();
|
||||
}, game.SelectedInstances[slot] == item.uid ? "loadout selected" : "loadout");
|
||||
}
|
||||
foreach (string blueprint in save.blueprints)
|
||||
{
|
||||
string id = blueprint;
|
||||
bool unlocked = save.unlocked.Contains(id);
|
||||
var b = Button(content, L(unlocked ? "ui.home.researched" : "ui.home.research", GameLocalization.ItemName(id)), () => game.Unlock(id), "loadout");
|
||||
b.SetEnabled(!unlocked);
|
||||
var recipe = InventoryGrid.Recipe(id);
|
||||
if (unlocked && recipe.item != null)
|
||||
Button(content, L("ui.home.manufacture", L("equipment." + recipe.item + ".name"), recipe.alloy, recipe.cells),
|
||||
() => { if (!game.Store.Manufacture(id)) game.Notify("notice.saveFailed"); ShowHangar(); }, "loadout")
|
||||
.SetEnabled(!save.run.active && save.alloy >= recipe.alloy && save.cells >= recipe.cells);
|
||||
}
|
||||
hangarNotice = Text(content, game.Notice, "warning");
|
||||
RefreshHangarNotice();
|
||||
@@ -328,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", K("Cargo")));
|
||||
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);
|
||||
@@ -351,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);
|
||||
@@ -413,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;
|
||||
@@ -420,7 +428,8 @@ namespace Ironspore
|
||||
if (status != null)
|
||||
status.text = L("ui.hud.status", game.Trial ? L("ui.home.range") : L("ui.hud.zone", run.stage + 1), TimeSpan.FromSeconds(run.elapsed).ToString(@"mm\:ss"));
|
||||
if (mission != null)
|
||||
mission.text = L(run.routeCompleted ? "ui.hud.complete" : run.stage == 4 && !game.Trial ? run.branch == 0 ? "ui.hud.boss" : "ui.hud.escort" : run.objectiveDone ? "ui.hud.routeOpen" : "ui.hud.objective");
|
||||
mission.text = game.World.Course ? game.World.Course.Instruction :
|
||||
L(run.routeCompleted ? "ui.hud.complete" : run.stage == 4 && !game.Trial ? run.branch == 0 ? "ui.hud.boss" : "ui.hud.escort" : run.objectiveDone ? "ui.hud.routeOpen" : "ui.hud.objective");
|
||||
if (armor != null)
|
||||
{
|
||||
armor.value = p.Health / game.chassis[run.chassis].armor * 100;
|
||||
|
||||
@@ -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
|
||||
@@ -53,6 +53,12 @@
|
||||
.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); }
|
||||
|
||||
@@ -167,6 +167,7 @@ MonoBehaviour:
|
||||
uiStyle: {fileID: 7433441132597879392, guid: 0e7ea2eb95ce6a54c91dac8db7248e77, type: 3}
|
||||
uiPanel: {fileID: 11400000, guid: 1d2be989bdcd9664fb0d95b25b0c5041, type: 2}
|
||||
localization: {fileID: 11400000, guid: b7a4f571f675a4d45ab5b1c6318c72db, type: 2}
|
||||
lightingProfile: {fileID: 11400000, guid: 8a14acd5fe57dab45b31eb59cc0c473a, type: 2}
|
||||
startInRange: 0
|
||||
SelectedChassis: 1
|
||||
InitialEquipment: 00000000000000000000000000000000i
|
||||
|
||||
@@ -167,6 +167,7 @@ MonoBehaviour:
|
||||
uiStyle: {fileID: 7433441132597879392, guid: 0e7ea2eb95ce6a54c91dac8db7248e77, type: 3}
|
||||
uiPanel: {fileID: 11400000, guid: 1d2be989bdcd9664fb0d95b25b0c5041, type: 2}
|
||||
localization: {fileID: 11400000, guid: b7a4f571f675a4d45ab5b1c6318c72db, type: 2}
|
||||
lightingProfile: {fileID: 11400000, guid: 8a14acd5fe57dab45b31eb59cc0c473a, type: 2}
|
||||
startInRange: 1
|
||||
SelectedChassis: 1
|
||||
InitialEquipment: 00000000000000000000000000000000i
|
||||
|
||||
@@ -167,6 +167,7 @@ MonoBehaviour:
|
||||
uiStyle: {fileID: 7433441132597879392, guid: 0e7ea2eb95ce6a54c91dac8db7248e77, type: 3}
|
||||
uiPanel: {fileID: 11400000, guid: 1d2be989bdcd9664fb0d95b25b0c5041, type: 2}
|
||||
localization: {fileID: 11400000, guid: b7a4f571f675a4d45ab5b1c6318c72db, type: 2}
|
||||
lightingProfile: {fileID: 11400000, guid: 8a14acd5fe57dab45b31eb59cc0c473a, type: 2}
|
||||
startInRange: 0
|
||||
SelectedChassis: 1
|
||||
InitialEquipment: 00000000000000000000000000000000i
|
||||
|
||||
@@ -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
|
||||
@@ -98,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()
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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)。
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
## 按键
|
||||
|
||||
15 项可改绑输入包含四个移动方向、主武器、精瞄、推进、跳跃/升空、视角、交互、货仓、回收舱、暂停、副武装和主动防护。镜头移动固定使用鼠标位移。
|
||||
16 项可改绑输入包含四个移动方向、主武器、精瞄、推进、跳跃/升空、视角、交互、货仓、回收舱、暂停、副武装、主动防护和换弹。货仓内使用 R 旋转选中的物品,驾驶时换弹读取当前绑定。镜头移动固定使用鼠标位移。
|
||||
|
||||
使用 Input System 自带的交互式改绑与 JSON overrides,并用绑定 ID 保持模板兼容。`SettingsInputCapture` 将 UI Toolkit 的键盘和指针事件适配到同一改绑服务,兼容辅助输入和未转发为原始设备事件的输入;两条路径共用冲突检查,且只完成一次。接受实际键盘按键及五个鼠标按钮;重复键、鼠标位移和不支持的设备不覆盖原绑定。Esc 取消采集并保留为菜单返回键。采集期间禁用驾驶动作,完成、取消、失焦和关闭时恢复原有启用状态并释放采集对象。完成采集后同时抑制当前帧和下一帧的菜单输入,避免被采集的按键继续关闭界面。
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
## 持久化与开发隔离
|
||||
|
||||
`settings.json` 使用独立的 `version = 1`,不修改 `profile.json`、`range.json` 的字段、版本或回收事件编号。数值载入时检查范围,损坏的主文件尝试从备份恢复。设置文件中的按键覆盖也会校验,避免将镜头向量输入替换成按钮输入。
|
||||
`settings.json` 使用独立的 `version = 1`,装备进度分别保存在 `profile-v2.json`、`range-v2.json`,两者通过同一数据目录入口隔离。数值载入时检查范围,损坏的主文件尝试从备份恢复。设置文件中的按键覆盖也会校验,避免将镜头向量输入替换成按钮输入。
|
||||
|
||||
普通 Windows 玩家沿用原先 `Application.persistentDataPath/Ironspore/` 路径及 `settings.locale` 语言偏好键。Unity 编辑器使用各工程自身的 `UserSettings/Ironspore/`,并按完整路径隔离语言偏好。独立程序测试可传入 `-ironspore-data-path <绝对路径>`,该目录中的设置、进度与语言偏好均独立于正式数据。
|
||||
|
||||
|
||||
@@ -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 编辑器测试、独立程序操作与性能采样;本轮设计文档不代表功能或测试已经交付。
|
||||
@@ -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 分钟继续通过完整出击记录验收。
|
||||
|
||||
已执行的用例和剩余工作以本阶段验证记录为准。
|
||||
@@ -4,7 +4,11 @@
|
||||
|
||||
## 工程起点
|
||||
|
||||
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)。
|
||||
|
||||
2026-11-12 增补的主页语言切换及全局双语工作按 [多语言实施计划](LocalizationPlan.md) 的 L1–L7 执行,完成后继续原有美术、实战与性能工作。
|
||||
|
||||
@@ -89,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 · 完整试玩版
|
||||
|
||||
@@ -123,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 |
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# 地图真实光影优化计划
|
||||
|
||||
2026-11-12。工作区:`D:/vibeCoding/unityRouge/workTree/tree3`;分支:`codex/map-improvement`;审计基线:`0857e29`。本文件保留已确认的实施方案,当前成果与实测数据见 [光影验收记录](../QA/Lighting/Acceptance.md)。
|
||||
|
||||
用户已确认采用真实灯光、物体遮挡与动态阴影,在本机以 **1920 × 1080、60 FPS** 验收。方案沿用 Unity `6000.6.8f1`、Built-in 渲染管线,采用 Linear 色彩空间、Standard PBR 材质、真实灯光和几何体投影。
|
||||
|
||||
## 效果目标与范围
|
||||
|
||||
- 太阳、工业灯具的方向、颜色、照射范围与可见光源一致;货箱、墙体、机甲和运输机产生对应的遮挡与动态投影。
|
||||
- 机甲在移动、转身、推进、跳跃和切换视角时持续投影。脚部、货箱底部和坡道连接处阴影贴合;高架下方有可信的明暗层次。
|
||||
- 地面、涂装、裸露金属体现不同的漫反射、金属度和光滑度;阴影中仍能识别敌人、拾取物和通路。
|
||||
- 导航设备和撤离坪需要照亮周围时配备实际 Light;自发光材质用于灯面显示。阴影验收以实际灯光和几何遮挡计算为依据,脚下黑片或阴影贴花不能代替机甲投影。
|
||||
- 优先完成直接光与动态阴影。间接光和环境反射使用各自的近似或预计算方案,验收记录明确其更新范围;本计划不承诺完整动态全局光照或实时镜面反射。
|
||||
|
||||
## 实施前基线与硬件
|
||||
|
||||
下表记录实施前的工程静态审计及本机硬件。实施后的设置、画面与性能以验收记录为准。
|
||||
|
||||
| 项目 | 当前事实 | 对计划的影响 |
|
||||
| --- | --- | --- |
|
||||
| 渲染管线 | `GraphicsSettings.asset` 未绑定 SRP,包清单未安装 URP/HDRP。 | 在现有 Built-in 上完成首轮优化。 |
|
||||
| 色彩空间 | `ProjectSettings.asset` 的 `m_ActiveColorSpace` 为 `0`,当前为 Gamma。 | 切换 Linear 后重新校准灯光、代码生成颜色、材质与 UI 显示。 |
|
||||
| 主光源 | `WarzoneDirector.ResetWorld()` 创建方向光,旋转 `(48, -35, 0)`、强度 `1.7`、暖色,已启用 Soft Shadows。 | 优化已有真实主光和阴影链路。 |
|
||||
| 画质 | Standalone 默认 Ultra,阴影距离 150 米、4 级联;Medium 是 20 米、低分辨率、硬阴影;Low/Very Low 关闭阴影。 | 重新定义可交付档位,并明确中等画质的实际参数。 |
|
||||
| 地图生命周期 | 三个入口场景保存 GameSession;地面、掩体、灯光等在运行时创建,`ResetWorld()` 禁用并销毁旧 World 根节点。 | 现有空场景直接烘焙不能覆盖运行时地图;首轮采用实时直接光。 |
|
||||
| 环境光与雾 | 运行时代码设置 `ambientLight`、雾色和密度,但未显式指定 `ambientMode`;入口场景设置为 Skybox 环境模式。 | 实施时回读有效环境模式,统一天空、环境光、主光与雾,分别校准机库和战区。 |
|
||||
| 材质与灯条 | `WorldFactory` 使用 Standard,普通表面统一设置 Metallic `0.3`、Glossiness `0.35`;灯条主要设置 Emission,未创建对应局部 Light。 | 按表面类型校准材质,对需要环境照明的关键灯具补齐实际灯光。 |
|
||||
| 第一人称 | `MechPilot.LateUpdate()` 使用 `visual.gameObject.SetActive(!FirstPerson)`;机体和舱框当前均使用第 8 层。 | 修正隐藏方式,保留世界投影;不能简单剔除整个第 8 层。 |
|
||||
| 帧率设置 | `GameSession.Awake()` 设置目标 120 FPS,现有常用画质开启 VSync;本机显示输出报告 300 Hz。 | 记录限帧与同步条件,分开判断渲染成本与等待时间。 |
|
||||
| CPU / 内存 | AMD Ryzen 9 5900HX,8 核 16 线程;系统识别物理内存约 31.4 GiB。 | 以本机为首轮验收设备。 |
|
||||
| GPU | NVIDIA GeForce RTX 3070 **Laptop GPU**,8192 MiB,NVIDIA 驱动 `581.80`;另有 AMD 核显。 | 独立程序回读实际渲染 GPU、API 和分辨率,确认使用 NVIDIA 显卡。 |
|
||||
| MCP 状态 | 本次 Unity MCP 实例列表为空。 | 本轮仅作静态规划;后续先完成准确实例绑定、导入和编译检查。 |
|
||||
|
||||
代码入口:[WarzoneDirector.cs](../../Assets/Game/Scripts/Runs/WarzoneDirector.cs)、[WorldFactory.cs](../../Assets/Game/Scripts/Combat/WorldFactory.cs)、[MechPilot.cs](../../Assets/Game/Scripts/Mechs/MechPilot.cs)、[GameSession.cs](../../Assets/Game/Scripts/Runs/GameSession.cs)。
|
||||
|
||||
## 实施顺序与完成条件
|
||||
|
||||
| 阶段 | 工作内容 | 完成条件与产出 |
|
||||
| --- | --- | --- |
|
||||
| S0 · 基线 | 绑定 tree3 的准确 Unity `Name@hash`,回读完整工程路径、活动场景、编辑器状态;完成导入与编译检查,隔离存档和 PlayerPrefs;保存现有画面、灯光配置及性能基线。 | 工程身份与数据隔离可核查;同一路线、同一配置有优化前截图及采样。 |
|
||||
| S1 · 光照基础 | 通过 Unity MCP 设置 Linear;保留现有白昼工业环境,分别校准机库和战区的主光、天空/环境光、雾;检查代码颜色和导入纹理的色彩语义。 | 亮部保留细节,阴影可辨认,天空与主光方向一致;材质及中英 UI 颜色回归通过。 |
|
||||
| S2 · 真实投影 | 校准主光阴影距离、级联、分辨率、Bias、Normal Bias 和近裁剪;核对 Cast/Receive Shadows;修复第一人称机体隐藏导致投影消失;整理舱框、文字、细小标记的投影策略。 | 两种视角下机甲持续投影;脚部与箱底不悬浮,阴影无明显条纹、漏光和近处跳变。 |
|
||||
| S3 · 灯具与表面 | 对导航设备、撤离坪和机库代表灯具设置实际局部光,优先受控的 Spot Light;按涂装、裸金属、地面分类调整材质;补充环境反射探针。 | 关键灯具能够照亮邻近物体,遮挡物阻断光线;金属反射与当前位置环境相符;局部灯开关和遮挡对比可复现。 |
|
||||
| S4 · 间接光样例(有条件) | 当高架下或机库等位置仍缺少间接光层次时,先在一个固定环境区域验证静态烘焙、Light Probes 与反射探针;再决定是否推广。 | 烘焙数据匹配实际场景、布局和加载生命周期;动态机甲跨越明暗区域自然过渡。前置条件不满足时记录延期,避免无效烘焙。 |
|
||||
| S5 · 性能收敛 | 对三个候选画质档逐项采样,依据实测调整阴影覆盖、局部灯数量和探针更新;定位 CPU、GPU 与同步等待各自成本。 | 本机中等画质代表场景 P95 帧耗时 ≤ 16.7 ms;记录真实参数及未达标项。 |
|
||||
| S6 · 独立构建验收 | Unity MCP 发起 Windows x64 构建并轮询完成,运行完整产物目录;验证双视角、连续进出战区、回收流程和中英显示。 | 归档画面、构建与 Console 结果、性能采样和回归结论;区分编辑器检查与独立程序实测。 |
|
||||
|
||||
S0 → S1 → S2 → S3 是核心效果路径;S4 根据实际画面和固定环境条件决定执行;S5、S6 为交付必需检查。每阶段单独保留差异与回退点,小范围更新现有入口,不运行重建全部原型场景的生成命令。
|
||||
|
||||
## 关键实现约束
|
||||
|
||||
### Linear、环境与材质
|
||||
|
||||
Linear 校准统一处理材质、天空、灯光与纹理采样;代码生成的颜色须核对输入语义,避免重复进行 sRGB/Linear 转换。涂装和非金属表面使用符合其性质的金属度,裸金属单独调整;以标准参考球和同一固定镜头比较结果。UI、世界文字和既有双语颜色同时复查。[Unity 色彩空间说明](https://docs.unity.com/en-us/engine/6000.6/manual/materials-and-shaders/graphics-color/color-spaces/set-project-color-space)
|
||||
|
||||
环境光、雾和主光的有效参数由明确配置统一应用;`ResetWorld()` 后检查没有残留旧灯和跨场景参数污染。美术配置沿用 `Assets/Game/Data/`、`Assets/Game/Art/`,按现有生成入口应用,保留碰撞、交互点和资源 GUID。
|
||||
|
||||
### 第一人称和近处阴影
|
||||
|
||||
优先以缓存的 Renderer 切换投影模式:第三人称正常显示并投影;第一人称使用 `ShadowCastingMode.ShadowsOnly` 保留机体投影。驾驶舱独立处理,防止贴相机的舱框向地图投射不合理的大阴影。该方案在实施时验证与机体动画、部件显隐和相机行为的兼容性。[Unity ShadowsOnly API](https://docs.unity.com/en-us/engine/6000.6/script-reference/unityengine/rendering/shadowcastingmode/shadowsonly)
|
||||
|
||||
按机甲约 3.2 米的尺度检查 Bias 与 Normal Bias,覆盖脚底、货箱接地、高架和斜坡。级联用于分配近远处阴影精度;软阴影是阴影图过滤效果,实际质量以近距离和移动画面验收。[Unity 阴影级联说明](https://docs.unity.com/en-us/engine/6000.6/manual/lighting-overview/shadows/shadow-realtime/shadow-cascades)
|
||||
|
||||
### 局部灯与反射
|
||||
|
||||
有环境照明职责的灯具应在同一位置配置实际 Light;需要遮挡的灯启用阴影并限制影响范围。用更少、范围更准确的灯满足预算;亮面显示使用 Emission,并检查其实际 GI 配置,不能仅凭材质发亮判定它照亮了环境。[Unity Standard 材质与 Emission](https://docs.unity.com/en-us/engine/6000.6/manual/materials-and-shaders/built-in/shader-built-in-birp/shader-standard-shader/standard-shader-material-parameters)
|
||||
|
||||
运行时地图生成完成后,由脚本触发环境反射探针捕获,避免创建过早而记录空地图。首轮捕获只包含环境,机甲、敌人等可移动主体不写入长期不更新的环境反射;默认按加载或环境变化触发,测量捕获峰值,避免所有探针每帧重绘。其结果用于环境镜面响应,不作为实时动态镜像验收。[Unity 反射探针性能说明](https://docs.unity.com/en-us/engine/6000.6/manual/lighting-overview/reflections/ref-probe-performance)
|
||||
|
||||
### 间接光与当前运行时布局
|
||||
|
||||
烘焙前必须把样例区域的固定几何和灯光落实为编辑时可见、具有有效 UV 的场景内容,保存 Lighting Data。静态环境及探针应具有独立于临时 World 根节点的明确生命周期,进入区域时保证环境只创建一次。不得同时保留重复的运行时地面、碰撞体或主光。
|
||||
|
||||
移动机甲、敌人、货舱和运输机继续接受实时直接光,并在有效的 Light Probes 范围内采样预计算间接光;需要覆盖地面、高架和轻型机体升空范围。首轮只迁移实际需要的固定区域,并保留搜索点、任务、存档和回收事件编号。[Unity 动态物体与 Light Probes](https://docs.unity.com/en-us/engine/6000.6/manual/lighting-overview/direct-and-indirect-lighting/light-probes/moving-objects)
|
||||
|
||||
## 候选画质与性能预算
|
||||
|
||||
下表保留起始调参值;基础、中等、增强三档现已实现并完成本机独立采样,实际结果见验收记录。光照真实遮挡与机甲投影是各交付档共同要求。
|
||||
|
||||
| 参数 | 基础档 | 中等档(验收档) | 增强档 |
|
||||
| --- | --- | --- | --- |
|
||||
| 主光源 | 1 个方向光、硬阴影 | 1 个方向光、软阴影 | 1 个方向光、软阴影 |
|
||||
| 阴影距离起点 | 35 米 | 60 米 | 100 米 |
|
||||
| 阴影级联 | 2 | 2,必要时对比 4 | 4 |
|
||||
| 主光阴影图目标等级 | 1024 | 2048 | 4096 |
|
||||
| 同时启用的额外投影灯 | 0 | 最多 1 个 Spot | 最多 2 个 Spot |
|
||||
| 环境反射探针起始分辨率 | 128 | 128–256 | 256 |
|
||||
| 探针刷新 | 地图生成完成后按需捕获 | 地图生成完成后按需捕获 | 按变化与测量结果限频更新 |
|
||||
|
||||
调整现有 QualitySettings 档位时核对 Standalone 默认档,避免仍以 Ultra 代替“中等画质”验收;同步检查 Built-in Forward 的逐像素光数量,保证预算内的关键灯不会意外降级。Low/Very Low 若继续作为交付选项,也须保留受控范围的主光投影。
|
||||
|
||||
中等档先以新增光照/阴影的 GPU 增量不超过约 3 ms 作为内部调参预算,同时要求最终整帧 P95 ≤ 16.7 ms。增量预算需由 S0 基线校准,不能由硬件型号推断达标。超预算时依次减少探针刷新、额外投影灯与影响范围、无意义的小物体投影,再调整级联、距离和分辨率,保留机甲及主要掩体的真实投影。
|
||||
|
||||
## 验收与记录
|
||||
|
||||
### 固定观察位置
|
||||
|
||||
1. 机库展示台:三种机体、顶部横梁遮挡、金属反射和首页文字。
|
||||
2. 战区出生点:顺光、侧光、逆光与远近掩体。
|
||||
3. 货箱旁:脚部、箱底、遮挡边缘与阴影中敌人辨识。
|
||||
4. 高架下与坡道:背阴层次、坡面接触、快速移动和阴影级联过渡。
|
||||
5. 导航设备:灯光关闭、开启、插入遮挡物三组对照,确认真实照明和遮挡。
|
||||
6. 撤离坪:局部灯、运输机抵达与货舱升空的动态投影。
|
||||
|
||||
两种驾驶视角均检查,机甲移动与光源/遮挡变化保留短录屏。三种底盘至少覆盖一次投影与舱内检查;首领和护送阶段追加代表性高负载检查。固定相机、布局/随机种子和分辨率形成前后对照,临时验证改动在记录完成后恢复。
|
||||
|
||||
### 性能采样
|
||||
|
||||
- 使用本机独立 Windows 程序,记录 CPU、GPU、驱动、图形 API、物理分辨率、渲染分辨率、实际画质、阴影参数、VSync 和帧率上限。确认运行于 RTX 3070 Laptop GPU;使用固定供电与性能模式,并关闭其他游戏或 Unity 测试实例。
|
||||
- 场景预热 60 秒后,同一固定战斗路线分别以越肩和第一人称采样 120 秒,各重复 3 次。记录各次 P50/P95/P99 帧耗时、CPU/GPU 时间、Draw Calls、阴影成本和内存;加载及探针首次捕获峰值单列。
|
||||
- 诊断采样固定无 VSync、无限帧条件,以区分渲染成本;60 FPS 体验验收单独记录其实际限帧/同步设置。最终按 [项目规范](ProjectConventions.md) 检查中等画质代表场景的 P95 ≤ 16.7 ms,逐场景、逐视角报告结果。
|
||||
- 独立程序负责最终帧耗时证据;编辑器 Profiler/Frame Debugger 用于定位 Pass 和灯光成本。拿不到可靠 GPU 时间时明确标记缺项,不从平均 FPS 反推阴影 Pass 耗时。
|
||||
|
||||
### 回归与产物
|
||||
|
||||
双视角切换、近墙精瞄、移动/跳跃、搜索、跨战区、货舱回收、整机撤离和中英切换保持可用;连续机库 → 战区 → 机库至少 3 次,检查灯光/探针/相机数量和内存趋势,确保无重复环境及残留对象。运行现有相关 EditMode 测试,并通过对应 Unity MCP job 查询到完成。
|
||||
|
||||
计划实施后的 QA 进入 `Docs/QA/Lighting/`,包括实际验收结论、Screenshots、TestResults、Logs 和性能原始数据;Windows 完整产物进入 `Builds/Windows/x64/Lighting/`。新增 Unity 配置、材质、探针及其 `.meta` 经 Unity MCP 创建、保存与回读;源码仅作必要的光照入口和显隐修复。机体模型/动画及 UI 的并行改动在集成时逐项核对。
|
||||
|
||||
实施状态:S0 的身份绑定、数据隔离和历史基线已归档;控制路线后续修正,未完成同路线的精确光照增量比较。S1–S3 已完成实现与回归,Windows 构建及独立动态阴影对照通过;S5 的三个候选档和中等档正式 6×120 秒无限帧采样均通过,S6 的构建与验证记录已归档。额外 60 FPS 限帧实测平均为 60.01 FPS,但 P95 为 17.23–17.28 ms,严格 16.7 ms 判据未通过并保留原始结果。S4 所需的编辑时固定场景尚未落地,当前采用 Trilight 环境近似与实景反射;后续按静态环境条件推进烘焙与 Light Probes。单独阴影 Pass 耗时、初次捕获峰值及桌面工具未能触发的原生键盘动作均明确列为未验证项。详细完成项、原始数据与范围归档于 [光影验收记录](../QA/Lighting/Acceptance.md)。
|
||||
@@ -0,0 +1,34 @@
|
||||
# 驾驶试验场物品与动作扩充计划
|
||||
|
||||
2026-11-12,地图改善分支。本轮规划一条约 3–5 分钟的自选练习路线。玩家从出生点能看见训练入口,完成推进、命中或寄送后,能从场景物件本身看出结果,也能原地重练。路线时长和下表数量是首轮设计值,灰盒试驾后调整。
|
||||
|
||||
## 场地与练习
|
||||
|
||||
现有试验场与战区共用运行时布局。72 × 100 米的地面上已有 8 个集装箱、6 处搜索箱位、坡道、高架、导航终端和撤离坪。试验场生成 2 个敌人,试驾数据使用独立的 `range.json`。首版沿用这块场地,从出生点向前分流,玩家可跳过任一练习。
|
||||
|
||||
| 区域 | 物品与布置 | 动作与可见反馈 | 完成条件 |
|
||||
| --- | --- | --- | --- |
|
||||
| 出生点与机动区 | 双语路线牌、两道宽门、落点标线,接入右侧已有坡道和高架 | 穿门、推进、制动、跳跃落地。经过第一道门后指示灯依次点亮,到达平台后显示本轮用时 | 三种底盘均能走坡道完成。轻型升空可走上方捷径。折返穿同一道门只计一次 |
|
||||
| 左侧靶区 | 一组固定翻板靶、一组横移靶、靶架及复位台。用现有集装箱形成遮挡边界 | 越肩或第一人称瞄准。翻板命中后倒下,横移靶短暂停顿并亮起命中灯,复位后恢复初始位置 | 以枪口实际射线命中为准。准星看见靶但枪口被箱体挡住时不计分。一次命中只触发一次反馈 |
|
||||
| 搜索与回收区 | 将已有箱位中的两个设为示范箱,分别标识材料与可试装部件,沿用现有接应坪 | 搜索后箱盖打开,在货舱选货,试装部件或发射回收舱,最后可呼叫运输机并主动登机 | 中断寄送后货物仍在舱内,成功只结算一次。运输机抵达与玩家登机有各自可辨认的反馈 |
|
||||
|
||||
外观优先补齐靶架底座、轨道端挡、检修围栏和箱盖铰链。围栏只布置在路线边缘。靶板和箱盖提供本轮新增的物件动作,机甲动作沿用现有推进、跳跃、升空、精瞄和开火。训练装置由玩家主动启动,基础机动练习期间让现有敌人留在独立的对抗练习区,验证流弹与仇恨范围。
|
||||
|
||||
## 制作顺序
|
||||
|
||||
1. **搭一个能玩的样段。** 在 `StartTrial()` 进入的试驾分支中,先用灰盒搭一道训练门、一块翻板靶和复位台。与当前 `World` 一起创建和销毁,在灯光探针首次捕获前完成布置。先走通机库试驾入口和 `MechTestRange` 直入入口,再摆整场。
|
||||
2. **接入真实动作。** 在 `MechPilot.Shoot()` 的枪口命中处接入训练靶响应,保留现有敌人伤害与制导目标规则。翻板倒下时同步更新可命中状态,横移及复位随暂停冻结。复位台使用已有交互键并提供中英提示。场地内的可交互点按现有 4.5 米聚焦半径布置,避免箱子和复位台争抢提示。
|
||||
3. **完善路线与资产。** 加入横移靶、第二道门和两个示范箱的动作反馈。复用现有搜索、试装、选货、寄送及登机逻辑。复位台只重置本轮靶位和练习计数,搜索箱与寄送状态沿用试驾记录,阻止复位造成重复发奖。先验证重型底盘通行净宽和两种相机的遮挡,再替换模型和声音。
|
||||
4. **独立构建验收。** 通过 Unity MCP 核对 tree3 的完整工程路径、活动场景和编辑器状态,再执行编译、测试及 Windows 构建。使用机库正常入口走通整条路线,另从试验场入口重复进出三次,检查训练装置、相机、灯与碰撞体数量。回到正式战区核对既有箱位、敌人及目标。使用隔离测试档比对练习前后主档的字节与回收事件编号。
|
||||
|
||||
## 免费资产候选
|
||||
|
||||
Kenney 的 Blaster Kit 官方条目标有靶标与箱体,可从中试选一个靶面和一个箱体,先以机甲实际尺寸试装。Interface Sounds 可试听命中确认和复位提示,两包官方页均列为 CC0。轨道、门架和铰链可先用项目几何制作。[Blaster Kit](https://kenney.nl/assets/blaster-kit) · [Interface Sounds](https://kenney.nl/assets/interface-sounds)
|
||||
|
||||
实施时按实际采用文件记录作者、版本、下载日期、SHA-256 和许可原文。原件进 `SourceAssets/ThirdParty/`,导入物进 `Assets/ThirdParty/`,行为与碰撞挂在项目预制体上。需要修整的模型通过 Blender MCP 检查米制比例、转轴和朝向,再用 Unity MCP 核对导入结果。[素材台账](AssetSources.md)
|
||||
|
||||
## 光影与性能验收
|
||||
|
||||
录制一轮“横移 → 命中 → 倒靶或停顿 → 复位”,核对移动几何、碰撞状态和地面投影同步。将移动靶面排除在静态环境反射捕获之外,沿用中等档局部投影灯预算。中英两种语言分别检查说明牌、当前动作提示和计数。独立程序用实际键鼠核对视角切换、暂停和重新聚焦后的首次射击。
|
||||
|
||||
在本机 RTX 3070 Laptop 上,以原生 1080p、中等画质,对新增训练场做同路线灰盒与成品对照。预热 60 秒,越肩和第一人称各跑 3 × 120 秒,覆盖横移靶、机动和寄送同时可见的情况。记录平均 FPS、P95、P99、实际相机尺寸及渲染帧数,按 P95 ≤ 16.7 ms 检查。另测默认 60 FPS 限帧的帧间隔,沿用已有结果作参考。上轮限帧 P95 为 17.23–17.28 ms,训练场需重新实测。[光影验收记录](../QA/Lighting/Acceptance.md)
|
||||
@@ -0,0 +1,21 @@
|
||||
# 装备与弹射式安全箱验证
|
||||
|
||||
2026-11-12,Unity 6000.6.8f1,工作区 `tree1`,分支 `codex/models-weapons-equipment-animation`。
|
||||
|
||||
## 数据与编辑器测试
|
||||
|
||||
Unity MCP EditMode 作业 `304ef548c87d4fc6b6643e93b6c4588d` 通过 25/25 项。覆盖货舱形状与数量、装备与材料在容器间的归属、旋转和拆并、制造费用、同型号实例选择、借用件归还、机毁锁定、备份和写入失败重试。机毁后仅安全箱和此前主动送回的物品保全。首次运行发现动态字体图集为空,启动时预载翻译用字后,双语键、参数和字形测试通过。
|
||||
|
||||
## 编辑器试玩
|
||||
|
||||
- 从 Bootstrap 进入独立试验场。用 UI Toolkit 导航提交选择已装副武器,旋转并放入安全箱第二行,检查槽位为空、朝向与 UID。随后触发致命伤害,自动返回机库。试验场重新读取到同 UID 的一件装备,以及各一条弹射和返航收据。正式档未写入。
|
||||
- 在 1920×1080 中文界面通过指针事件把完整副武器从货仓拖进安全箱,再通过选择、旋转和目标格提交横放。普通货仓的合金与箱内武器在正常返航后均可查到。[界面截图](Grid-zh-1920x1080.png)
|
||||
- 切换 1280×720 英文界面后,保留所选物品和旋转状态。读取到 20 个格子按钮,按钮中心均在面板内。编辑器退出播放后恢复原来的未命名场景,清理本次生成的试验场临时存档。
|
||||
|
||||
## Windows x64
|
||||
|
||||
Unity MCP 构建作业 `build-00b68c9a5c` 成功,0 错误、0 警告。产物目录为 `Builds/Windows/x64/Equipment/`。从该目录启动 Ironspore.exe,确认 Unity 创建 1280×720 客户区、初始化 D3D12 和输入系统,并响应关闭消息正常退出。独立程序本轮验证到启动和退出;上述装备操作在编辑器内验证。
|
||||
|
||||
## 后续验收
|
||||
|
||||
逐件搜取及地面丢弃回拾、弹射箱模型与飞行动画、双视角武器动作、完整独立程序出击和性能采样仍待实施或验证。当前默认容量为货仓 4×4、安全箱与主动货舱各 2×2。装备制造使用试玩费用;低配借用件返航后归还。
|
||||
|
After Width: | Height: | Size: 298 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 68 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 466 KiB |
|
After Width: | Height: | Size: 473 KiB |
|
After Width: | Height: | Size: 375 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 522 KiB |
|
After Width: | Height: | Size: 547 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 196 KiB |
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 549 KiB |
|
After Width: | Height: | Size: 473 KiB |
|
After Width: | Height: | Size: 375 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 496 KiB |
|
After Width: | Height: | Size: 547 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 196 KiB |
@@ -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"
|
||||
]
|
||||
}
|
||||