feat: establish mech extraction prototype with bilingual UI

Track Unity source, scenes, editable assets, licenses, tools and QA evidence. Exclude generated caches, Windows builds and raw runtime logs.
This commit is contained in:
2026-11-12 12:28:40 +08:00
commit 92623a1bc8
256 changed files with 11842 additions and 0 deletions
+423
View File
@@ -0,0 +1,423 @@
using System;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UIElements;
namespace Ironspore
{
public sealed class GameHud : MonoBehaviour
{
GameSession game;
VisualElement root;
Label mission, prompt, status, notice, telemetry;
ProgressBar armor, energy, cargo;
VisualElement cross;
enum View
{
Hangar, Field, Cargo, Part, Routes, Pause
}
View view;
int partSlot, partIndex;
PanelSettings panelSettings;
VisualElement languageControl, languageMenu, languageDismiss;
Button languageButton;
int lastLanguageActivationFrame = -1;
int lastLanguageNavigationFrame = -1;
Label hangarNotice;
static string L(string key, params object[] args) => GameLocalization.Text(key, args);
public void Initialize(GameSession session)
{
game = session;
var document = gameObject.AddComponent<UIDocument>();
var settings = panelSettings = Instantiate(game.uiPanel);
settings.scaleMode = PanelScaleMode.ScaleWithScreenSize;
settings.referenceResolution = new Vector2Int(1920, 1080);
document.panelSettings = settings;
root = document.rootVisualElement;
root.styleSheets.Add(game.uiStyle);
root.pickingMode = PickingMode.Ignore;
root.style.unityFontDefinition = FontDefinition.FromSDFFont(game.localization.uiFont);
root.RegisterCallback<KeyDownEvent>(HandleLanguageKey, TrickleDown.TrickleDown);
GameLocalization.Changed += RefreshLocale;
}
void HandleLanguageKey(KeyDownEvent evt)
{
if (view != View.Hangar)
return;
if (languageMenu != null && evt.keyCode == KeyCode.Escape)
{
CloseLanguageMenu();
evt.StopImmediatePropagation();
return;
}
if (languageMenu != null && evt.keyCode == KeyCode.Tab)
{
MoveLanguageFocus(evt.shiftKey);
evt.StopImmediatePropagation();
return;
}
if (evt.keyCode != KeyCode.Return && evt.keyCode != KeyCode.KeypadEnter && evt.keyCode != KeyCode.Space)
return;
if (ActivateFocusedLanguage())
evt.StopImmediatePropagation();
}
void MoveLanguageFocus(bool reverse)
{
if (lastLanguageNavigationFrame == Time.frameCount)
return;
lastLanguageNavigationFrame = Time.frameCount;
var options = languageMenu.Query<Button>(className: "language-option").ToList();
int current = options.IndexOf(root.focusController.focusedElement as Button);
int next = (current + (reverse ? -1 : 1) + options.Count) % options.Count;
options[next].Focus();
}
bool ActivateFocusedLanguage()
{
var focused = root.focusController.focusedElement as Button;
if (focused == languageButton)
ToggleLanguageMenu(languageControl.parent);
else if (languageMenu != null && focused != null && focused.ClassListContains("language-option"))
ChooseLocale((string)focused.userData);
else
return false;
return true;
}
VisualElement Element(VisualElement parent, string css)
{
var e = new VisualElement();
e.AddToClassList(css);
parent.Add(e);
return e;
}
Label Text(VisualElement parent, string text, string css = "body")
{
var l = new Label(text);
l.AddToClassList(css);
parent.Add(l);
return l;
}
Button Button(VisualElement parent, string text, Action action, string css = "button")
{
var b = new Button(action) { text = text };
foreach (string className in css.Split(' '))
b.AddToClassList(className);
parent.Add(b);
return b;
}
void Clear()
{
root.Clear();
mission = prompt = status = notice = telemetry = null;
armor = energy = cargo = null;
cross = null;
hangarNotice = null;
languageControl = languageMenu = languageDismiss = null;
languageButton = null;
}
void RefreshLocale()
{
switch (view)
{
case View.Hangar:
ShowHangar();
languageButton?.Focus();
break;
case View.Field:
ShowField();
break;
case View.Cargo:
ShowCargo();
break;
case View.Part:
ShowPart(partSlot, partIndex);
break;
case View.Routes:
ShowRoutes();
break;
case View.Pause:
ShowPause();
break;
}
}
void AddLanguageControl(VisualElement page)
{
languageControl = Element(page, "language-control");
var locale = Array.Find(GameLocalization.Catalog.locales, x => x.code == GameLocalization.CurrentCode);
languageButton = Button(languageControl, L("ui.home.language", locale.nativeName), () => ToggleLanguageMenu(page), "language-button");
languageButton.name = "language-toggle";
languageButton.tooltip = L("ui.home.languageHelp");
}
void ToggleLanguageMenu(VisualElement page)
{
if (lastLanguageActivationFrame == Time.frameCount)
return;
lastLanguageActivationFrame = Time.frameCount;
if (languageMenu != null)
CloseLanguageMenu();
else
OpenLanguageMenu(page);
}
void ChooseLocale(string code)
{
if (lastLanguageActivationFrame == Time.frameCount)
return;
lastLanguageActivationFrame = Time.frameCount;
CloseLanguageMenu();
GameLocalization.SelectLocale(code);
}
void OpenLanguageMenu(VisualElement page)
{
languageDismiss = Element(page, "language-dismiss");
languageDismiss.RegisterCallback<PointerDownEvent>(evt => { CloseLanguageMenu(); evt.StopPropagation(); });
languageControl.BringToFront();
languageMenu = Element(languageControl, "language-menu");
languageMenu.name = "language-menu";
Text(languageMenu, L("ui.home.languageHelp"), "language-heading");
Button selected = null;
foreach (var locale in GameLocalization.Catalog.locales)
{
string code = locale.code;
bool current = code == GameLocalization.CurrentCode;
var option = Button(languageMenu, current ? L("ui.language.current", locale.nativeName) : locale.nativeName, () => ChooseLocale(code), current ? "language-option selected" : "language-option");
option.name = "locale-" + code;
option.userData = code;
if (current)
selected = option;
}
selected?.Focus();
}
public void CloseLanguageMenu()
{
languageMenu?.RemoveFromHierarchy();
languageDismiss?.RemoveFromHierarchy();
languageMenu = languageDismiss = null;
languageButton?.Focus();
}
public void RefreshHangarNotice()
{
if (hangarNotice != null)
{
hangarNotice.text = game.Notice;
hangarNotice.style.display = string.IsNullOrEmpty(game.Notice) ? DisplayStyle.None : DisplayStyle.Flex;
}
}
public void ShowHangar()
{
Clear();
view = View.Hangar;
var page = Element(root, "hangar");
var scroll = new ScrollView(ScrollViewMode.Vertical) { horizontalScrollerVisibility = ScrollerVisibility.Hidden };
scroll.AddToClassList("hangar-scroll");
page.Add(scroll);
var content = Element(scroll, "hangar-content");
Text(content, "IRONSPORE", "title");
Text(content, L("ui.home.subtitle"), "subtitle");
Text(content, game.Report, "body");
var frames = Element(content, "row");
for (int i = 0; i < game.chassis.Length; i++)
{
int choice = i;
var frame = game.chassis[i];
Button(frames, frame.LocalizedName + "\n" + frame.LocalizedDescription, () => { game.SelectedChassis = choice; ShowHangar(); }, i == game.SelectedChassis ? "frame selected" : "frame");
}
Text(content, L("ui.home.loadout"), "section");
for (int slot = 0; slot < 4; slot++)
{
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");
}
float mass = 0, power = 0;
for (int i = 0; i < 4; i++)
{
var p = game.Part(i, game.InitialEquipment[i]);
mass += p.mass;
power += p.power;
}
var selected = game.chassis[game.SelectedChassis];
Text(content, L("ui.home.budget", mass, selected.massCapacity, power, selected.powerCapacity), game.Fits(game.SelectedChassis, game.InitialEquipment) ? "budget" : "warning");
var controls = Element(content, "row");
var launch = Button(controls, L("ui.home.deploy"), game.BeginSortie, "primary");
launch.SetEnabled(game.Fits(game.SelectedChassis, game.InitialEquipment) && game.StartingPartsUnlocked() && !game.Store.Data.run.active);
if (!game.StartingPartsUnlocked())
Text(content, L("ui.home.locked"), "warning");
Button(controls, L("ui.home.range"), game.StartTrial);
if (game.Store.Data.run.active)
Button(controls, L("ui.home.resume"), game.Resume, "primary");
Text(content, L("ui.home.moveHint"), "hint");
Text(content, L("ui.home.fireHint"), "hint");
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 (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);
}
hangarNotice = Text(content, game.Notice, "warning");
RefreshHangarNotice();
Button(content, L("ui.home.quit"), () => Application.Quit(), "quiet");
AddLanguageControl(page);
}
public void ShowField()
{
Clear();
view = View.Field;
var field = Element(root, "field");
field.pickingMode = PickingMode.Ignore;
var top = Element(field, "topbar");
Text(top, "IRONSPORE", "brand");
status = Text(top, "", "status");
var goal = Element(field, "mission");
Text(goal, L("ui.hud.operation"), "section");
mission = Text(goal, "", "mission-text");
telemetry = Text(field, "", "telemetry");
cross = Element(field, "crosshair");
cross.style.position = Position.Absolute;
cross.style.left = Length.Percent(50);
cross.style.top = Length.Percent(50);
cross.style.translate = new Translate(-20, -20);
cross.style.width = 40;
cross.style.height = 40;
for (int i = 0; i < 4; i++)
{
var arm = Element(cross, "reticle-arm");
arm.style.position = Position.Absolute;
arm.style.backgroundColor = new Color(.64f, 1, .9f);
arm.style.left = i == 0 ? 0 : i == 1 ? 27 : 19;
arm.style.top = i == 2 ? 0 : i == 3 ? 27 : 19;
arm.style.width = i < 2 ? 13 : 2;
arm.style.height = i < 2 ? 2 : 13;
}
prompt = Text(field, "", "interact");
notice = Text(field, "", "notice");
var bottom = Element(field, "bottom");
armor = new ProgressBar { title = L("ui.hud.armor", 0), lowValue = 0, highValue = 100 };
armor.AddToClassList("armor");
bottom.Add(armor);
energy = new ProgressBar { title = L("ui.hud.energy", 0), lowValue = 0, highValue = 100 };
energy.AddToClassList("energy");
bottom.Add(energy);
cargo = new ProgressBar { title = L("ui.hud.podReady"), lowValue = 0, highValue = 3 };
cargo.AddToClassList("cargo-progress");
bottom.Add(cargo);
Text(bottom, L("ui.hud.hint"), "hint");
}
VisualElement Overlay(string heading, string body)
{
ShowField();
var shade = Element(root, "overlay");
var panel = Element(shade, "panel");
Text(panel, heading, "heading");
Text(panel, body, "body");
return panel;
}
public void ShowCargo()
{
var panel = Overlay(L("ui.cargo.title"), L(game.Run.podUsed ? "ui.cargo.used" : "ui.cargo.help"));
view = View.Cargo;
foreach (var stack in game.Run.cargo)
{
string uid = stack.uid;
var b = Button(panel, L("ui.cargo.stack", game.SelectedCargo.Contains(uid) ? L("ui.cargo.selected") : "[ ]", stack.Name, stack.count), () => game.ToggleCargo(uid), "loadout");
b.SetEnabled(!game.Run.podUsed);
}
Text(panel, L("ui.cargo.capacity", game.Run.cargo.Count, game.SelectedCargo.Count), "budget");
Button(panel, L("ui.return"), game.CloseMenu, "primary");
}
public void ShowPart(int slot, int index)
{
index = Mathf.Clamp(index, 0, game.PartCount(slot) - 1);
int partIndex = index;
this.partSlot = slot;
this.partIndex = index;
var old = game.Part(slot, game.Run.equipment[slot]);
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.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);
}
public void ShowRoutes()
{
bool split = game.Run.stage == 1;
var panel = Overlay(L(split ? "ui.routes.split" : "ui.routes.title"), L(split ? "ui.routes.splitHelp" : "ui.routes.help"));
view = View.Routes;
Button(panel, L(split ? "ui.routes.boss" : "ui.routes.freight"), () => game.NextZone(0), "route");
Button(panel, L(split ? "ui.routes.escort" : "ui.routes.power"), () => game.NextZone(1), "route");
Button(panel, L("ui.routes.stay"), game.CloseMenu);
}
public void ShowPause()
{
var panel = Overlay(L("ui.pause.title"), L("ui.pause.help"));
view = View.Pause;
Button(panel, L("ui.pause.resume"), game.CloseMenu, "primary");
if (game.Trial)
Button(panel, L("ui.pause.range"), () => { game.CloseMenu(); game.EndSortie(true); });
Button(panel, L("ui.pause.quit"), () => Application.Quit());
}
void Update()
{
if (view == View.Hangar)
{
RefreshHangarNotice();
if (languageMenu != null && game.Action("Pause").WasPressedThisFrame())
CloseLanguageMenu();
// The project uses Input System without a uGUI EventSystem. Handle submit
// explicitly as well as UI Toolkit events; frame guards prevent double input.
var keys = Keyboard.current;
if (keys != null)
{
if (languageMenu != null && keys.tabKey.wasPressedThisFrame)
MoveLanguageFocus(keys.shiftKey.isPressed);
if (keys.enterKey.wasPressedThisFrame || keys.numpadEnterKey.wasPressedThisFrame || keys.spaceKey.wasPressedThisFrame)
ActivateFocusedLanguage();
}
}
if (!game.InField || !game.Pilot)
return;
var run = game.Run;
var p = game.Pilot;
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");
if (armor != null)
{
armor.value = p.Health / game.chassis[run.chassis].armor * 100;
armor.title = L("ui.hud.armor", Mathf.CeilToInt(p.Health));
}
if (energy != null)
{
energy.value = p.Energy;
energy.title = p.Guard > 0 ? L("ui.hud.shield") : L("ui.hud.energy", Mathf.CeilToInt(p.Energy));
}
if (cargo != null)
{
cargo.value = game.CargoProgress;
cargo.title = L(run.podUsed ? "ui.hud.podUsed" : game.CargoProgress > 0 ? "ui.hud.podPreparing" : "ui.hud.podReady");
}
if (prompt != null)
{
prompt.text = game.World.Focused ? game.World.Focused.Prompt : "";
prompt.style.display = game.World.Focused ? DisplayStyle.Flex : DisplayStyle.None;
}
if (notice != null)
notice.text = game.Notice;
if (telemetry != null)
telemetry.text = L("ui.hud.telemetry", game.chassis[run.chassis].LocalizedName, L(p.FirstPerson ? "ui.hud.cockpit" : "ui.hud.shoulder"), game.Part(0, run.equipment[0]).LocalizedName, run.cargo.Count, game.World.ExtractionRemaining >= 0 ? game.World.TransportReady ? L("ui.hud.transportReady") : L("ui.hud.transportEta", Mathf.CeilToInt(game.World.ExtractionRemaining)) : L("ui.hud.extractionAvailable"));
if (cross != null)
cross.style.opacity = p.RecentHit ? 1 : .65f;
}
void OnDestroy()
{
GameLocalization.Changed -= RefreshLocale;
if (panelSettings)
Destroy(panelSettings);
}
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4a435bd49fc86bd4a8eb5572ab1b6f1c