From 97fbf187e3450a9f7a7df4b049dbe2e6a99a7630 Mon Sep 17 00:00:00 2001 From: RCiBot Date: Thu, 12 Nov 2026 18:01:54 +0800 Subject: [PATCH] feat(ui): add persistent bilingual settings and combat HUD --- Assets/Editor/PrototypeBuilder.cs | 9 +- Assets/Game/Data/Equipment/coil.asset | 2 + Assets/Game/Data/Equipment/rapid.asset | 2 + Assets/Game/Data/Equipment/siege.asset | 2 + Assets/Game/Input/GameInput.inputactions | 21 + Assets/Game/Localization/Tables/en.json | 340 +++++++++++++- Assets/Game/Localization/Tables/zh-Hans.json | 340 +++++++++++++- .../Scripts/Equipment/EquipmentDefinition.cs | 2 + .../Scripts/Localization/GameLocalization.cs | 2 +- Assets/Game/Scripts/Mechs/MechPilot.cs | 32 +- Assets/Game/Scripts/Runs/GameSession.cs | 20 +- Assets/Game/Scripts/Runs/WarzoneDirector.cs | 26 +- Assets/Game/Scripts/Save/GameDataPaths.cs | 42 ++ .../Game/Scripts/Save/GameDataPaths.cs.meta | 2 + Assets/Game/Scripts/Settings.meta | 8 + .../Game/Scripts/Settings/GameSettingsData.cs | 43 ++ .../Scripts/Settings/GameSettingsData.cs.meta | 2 + .../Scripts/Settings/InputBindingService.cs | 225 +++++++++ .../Settings/InputBindingService.cs.meta | 2 + .../Game/Scripts/Settings/SettingsService.cs | 152 ++++++ .../Scripts/Settings/SettingsService.cs.meta | 2 + Assets/Game/Scripts/Settings/SettingsStore.cs | 65 +++ .../Scripts/Settings/SettingsStore.cs.meta | 2 + Assets/Game/Scripts/UI/CombatInstruments.cs | 133 ++++++ .../Game/Scripts/UI/CombatInstruments.cs.meta | 2 + Assets/Game/Scripts/UI/GameHud.cs | 64 ++- .../Game/Scripts/UI/SettingsInputCapture.cs | 40 ++ .../Scripts/UI/SettingsInputCapture.cs.meta | 2 + Assets/Game/Scripts/UI/SettingsScreen.cs | 444 ++++++++++++++++++ Assets/Game/Scripts/UI/SettingsScreen.cs.meta | 2 + Assets/Game/UI/GameHud.uss | 62 +++ Assets/Tests/EditMode/Ironspore.Tests.asmdef | 2 +- Assets/Tests/EditMode/LocalizationTests.cs | 3 +- Assets/Tests/EditMode/SettingsTests.cs | 337 +++++++++++++ Assets/Tests/EditMode/SettingsTests.cs.meta | 2 + Docs/Design/Settings.md | 40 ++ Docs/QA/CombatHUD/Acceptance.md | 9 + .../QA/CombatHUD/Screenshots/first-person.png | Bin 0 -> 456300 bytes .../QA/CombatHUD/Screenshots/third-person.png | Bin 0 -> 483940 bytes Docs/QA/Settings/Acceptance.md | 58 +++ Docs/QA/Settings/Artifacts.json | 78 +++ Docs/QA/Settings/Logs/WindowsBuild.json | 16 + .../settings-audio-zh-1920x1080.png | Bin 0 -> 114726 bytes .../settings-controls-en-1920x1080-1.png | Bin 0 -> 153387 bytes .../settings-controls-zh-1920x1080.png | Bin 0 -> 148810 bytes .../settings-display-zh-1920x1080.png | Bin 0 -> 123232 bytes .../Screenshots/windows-bindings-saved-en.png | Bin 0 -> 65848 bytes .../windows-keyboard-return-after-locale.png | Bin 0 -> 86901 bytes .../windows-return-to-pause-zh.png | Bin 0 -> 83771 bytes .../windows-settings-from-pause-zh.png | Bin 0 -> 76536 bytes Docs/QA/Settings/TestResults/EditMode.json | 416 ++++++++++++++++ .../TestResults/PauseIntegration.json | 28 ++ README.md | 6 +- Tools/Unity/WindowsReadme.txt | 11 + 54 files changed, 3046 insertions(+), 52 deletions(-) create mode 100644 Assets/Game/Scripts/Save/GameDataPaths.cs create mode 100644 Assets/Game/Scripts/Save/GameDataPaths.cs.meta create mode 100644 Assets/Game/Scripts/Settings.meta create mode 100644 Assets/Game/Scripts/Settings/GameSettingsData.cs create mode 100644 Assets/Game/Scripts/Settings/GameSettingsData.cs.meta create mode 100644 Assets/Game/Scripts/Settings/InputBindingService.cs create mode 100644 Assets/Game/Scripts/Settings/InputBindingService.cs.meta create mode 100644 Assets/Game/Scripts/Settings/SettingsService.cs create mode 100644 Assets/Game/Scripts/Settings/SettingsService.cs.meta create mode 100644 Assets/Game/Scripts/Settings/SettingsStore.cs create mode 100644 Assets/Game/Scripts/Settings/SettingsStore.cs.meta create mode 100644 Assets/Game/Scripts/UI/CombatInstruments.cs create mode 100644 Assets/Game/Scripts/UI/CombatInstruments.cs.meta create mode 100644 Assets/Game/Scripts/UI/SettingsInputCapture.cs create mode 100644 Assets/Game/Scripts/UI/SettingsInputCapture.cs.meta create mode 100644 Assets/Game/Scripts/UI/SettingsScreen.cs create mode 100644 Assets/Game/Scripts/UI/SettingsScreen.cs.meta create mode 100644 Assets/Tests/EditMode/SettingsTests.cs create mode 100644 Assets/Tests/EditMode/SettingsTests.cs.meta create mode 100644 Docs/Design/Settings.md create mode 100644 Docs/QA/CombatHUD/Acceptance.md create mode 100644 Docs/QA/CombatHUD/Screenshots/first-person.png create mode 100644 Docs/QA/CombatHUD/Screenshots/third-person.png create mode 100644 Docs/QA/Settings/Acceptance.md create mode 100644 Docs/QA/Settings/Artifacts.json create mode 100644 Docs/QA/Settings/Logs/WindowsBuild.json create mode 100644 Docs/QA/Settings/Screenshots/settings-audio-zh-1920x1080.png create mode 100644 Docs/QA/Settings/Screenshots/settings-controls-en-1920x1080-1.png create mode 100644 Docs/QA/Settings/Screenshots/settings-controls-zh-1920x1080.png create mode 100644 Docs/QA/Settings/Screenshots/settings-display-zh-1920x1080.png create mode 100644 Docs/QA/Settings/Screenshots/windows-bindings-saved-en.png create mode 100644 Docs/QA/Settings/Screenshots/windows-keyboard-return-after-locale.png create mode 100644 Docs/QA/Settings/Screenshots/windows-return-to-pause-zh.png create mode 100644 Docs/QA/Settings/Screenshots/windows-settings-from-pause-zh.png create mode 100644 Docs/QA/Settings/TestResults/EditMode.json create mode 100644 Docs/QA/Settings/TestResults/PauseIntegration.json diff --git a/Assets/Editor/PrototypeBuilder.cs b/Assets/Editor/PrototypeBuilder.cs index 1ac8cff..aab7f57 100644 --- a/Assets/Editor/PrototypeBuilder.cs +++ b/Assets/Editor/PrototypeBuilder.cs @@ -68,6 +68,11 @@ namespace Ironspore.Editor p.mass = masses[i]; p.power = powers[i]; p.damage = i == 0 ? 15 : i == 1 ? 47 : i == 2 ? 90 : 65; + if (i < 3) + { + p.magazineSize = new[] { 36, 12, 6 }[i]; + p.reloadSeconds = new[] { 2f, 1.8f, 2.6f }[i]; + } p.fireRate = i == 0 ? 7 : i == 1 ? 1.7f : 1; p.guided = i == 3; p.speedFactor = i == 6 ? 1.2f : 1; @@ -119,8 +124,8 @@ namespace Ironspore.Editor actions.AddActionMap(map); map.AddAction("Move", InputActionType.Value).AddCompositeBinding("2DVector").With("Up", "/w").With("Down", "/s").With("Left", "/a").With("Right", "/d"); map.AddAction("Look", InputActionType.Value, "/delta"); - string[] actionNames = { "Fire", "Aim", "Boost", "Jump", "View", "Interact", "Inventory", "Cargo", "Pause", "Secondary", "Guard" }; - string[] bindings = { "/leftButton", "/rightButton", "/leftShift", "/space", "/v", "/e", "/tab", "/g", "/escape", "/q", "/f" }; + string[] actionNames = { "Fire", "Aim", "Boost", "Jump", "View", "Interact", "Inventory", "Cargo", "Pause", "Secondary", "Guard", "Reload" }; + string[] bindings = { "/leftButton", "/rightButton", "/leftShift", "/space", "/v", "/e", "/tab", "/g", "/escape", "/q", "/f", "/r" }; for (int i = 0; i < actionNames.Length; i++) map.AddAction(actionNames[i], InputActionType.Button, bindings[i]); File.WriteAllText("Assets/Game/Input/GameInput.inputactions", actions.ToJson()); diff --git a/Assets/Game/Data/Equipment/coil.asset b/Assets/Game/Data/Equipment/coil.asset index 6f6f0df..8777b92 100644 --- a/Assets/Game/Data/Equipment/coil.asset +++ b/Assets/Game/Data/Equipment/coil.asset @@ -18,6 +18,8 @@ MonoBehaviour: mass: 6 power: 6 damage: 47 + magazineSize: 12 + reloadSeconds: 1.8 fireRate: 1.7 speedFactor: 1 guided: 0 diff --git a/Assets/Game/Data/Equipment/rapid.asset b/Assets/Game/Data/Equipment/rapid.asset index 9404f80..b2950a8 100644 --- a/Assets/Game/Data/Equipment/rapid.asset +++ b/Assets/Game/Data/Equipment/rapid.asset @@ -18,6 +18,8 @@ MonoBehaviour: mass: 4 power: 3 damage: 15 + magazineSize: 36 + reloadSeconds: 2 fireRate: 7 speedFactor: 1 guided: 0 diff --git a/Assets/Game/Data/Equipment/siege.asset b/Assets/Game/Data/Equipment/siege.asset index 5cdf7dc..865508a 100644 --- a/Assets/Game/Data/Equipment/siege.asset +++ b/Assets/Game/Data/Equipment/siege.asset @@ -18,6 +18,8 @@ MonoBehaviour: mass: 9 power: 4 damage: 90 + magazineSize: 6 + reloadSeconds: 2.6 fireRate: 1 speedFactor: 1 guided: 0 diff --git a/Assets/Game/Input/GameInput.inputactions b/Assets/Game/Input/GameInput.inputactions index 398eaec..d9e6d81 100644 --- a/Assets/Game/Input/GameInput.inputactions +++ b/Assets/Game/Input/GameInput.inputactions @@ -135,6 +135,16 @@ "interactions": "", "initialStateCheck": false, "priority": 0 + }, + { + "name": "Reload", + "type": "Button", + "id": "bb99b681-9cce-48de-8c4f-4c34ebbd42ea", + "expectedControlType": "", + "processors": "", + "interactions": "", + "initialStateCheck": false, + "priority": 0 } ], "bindings": [ @@ -324,6 +334,17 @@ "action": "Guard", "isComposite": false, "isPartOfComposite": false + }, + { + "name": "", + "id": "2b1a88d9-c0ce-4c90-93c3-5b7114de2917", + "path": "/r", + "interactions": "", + "processors": "", + "groups": "", + "action": "Reload", + "isComposite": false, + "isPartOfComposite": false } ] } diff --git a/Assets/Game/Localization/Tables/en.json b/Assets/Game/Localization/Tables/en.json index b60691f..ba77b0a 100644 --- a/Assets/Game/Localization/Tables/en.json +++ b/Assets/Game/Localization/Tables/en.json @@ -50,11 +50,11 @@ }, { "key": "ui.home.moveHint", - "value": "WASD move / mouse aim / V perspective / Shift boost / Space jump & lift" + "value": "{0} move / mouse aim / {1} perspective / {2} boost / {3} jump & lift" }, { "key": "ui.home.fireHint", - "value": "LMB primary / RMB precision aim / Q secondary / F shield / E interact" + "value": "{0} primary / {1} precision aim / {2} secondary / {3} shield / {4} interact" }, { "key": "ui.home.inventory", @@ -114,11 +114,11 @@ }, { "key": "ui.hud.podReady", - "value": "POD READY / SELECT IN TAB" + "value": "POD READY / {0} SELECT CARGO" }, { "key": "ui.hud.hint", - "value": "V VIEW SHIFT BOOST SPACE LIFT Q SECONDARY F SHIELD E INTERACT TAB CARGO HOLD G SEND POD ESC PAUSE" + "value": "{0} VIEW {1} BOOST {2} LIFT {3} SECONDARY {4} SHIELD {5} INTERACT {6} CARGO HOLD {7} SEND POD {8} PAUSE" }, { "key": "ui.hud.zone", @@ -182,7 +182,7 @@ }, { "key": "ui.cargo.help", - "value": "Select up to two stacks. Close this panel, stand still and hold G for 3 seconds to send the pod." + "value": "Select up to two stacks. Close this panel, stand still and hold {0} for 3 seconds to send the pod." }, { "key": "ui.cargo.stack", @@ -318,7 +318,7 @@ }, { "key": "notice.range", - "value": "DRIVING RANGE / V switches perspective. E operates nearby devices." + "value": "DRIVING RANGE / {0} switches perspective. {1} operates nearby devices." }, { "key": "notice.enter", @@ -390,7 +390,7 @@ }, { "key": "notice.transportReady", - "value": "TRANSPORT READY / return to the orange pad and press E to board." + "value": "TRANSPORT READY / return to the orange pad and press {0} to board." }, { "key": "notice.escortLost", @@ -398,7 +398,7 @@ }, { "key": "prompt.search", - "value": "E SEARCH SALVAGE" + "value": "{0} SEARCH SALVAGE" }, { "key": "prompt.relayOnline", @@ -406,11 +406,11 @@ }, { "key": "prompt.objective", - "value": "E RECOVER NAVIGATION DATA" + "value": "{0} RECOVER NAVIGATION DATA" }, { "key": "prompt.route", - "value": "E CHOOSE ONWARD ROUTE" + "value": "{0} CHOOSE ONWARD ROUTE" }, { "key": "prompt.routeLocked", @@ -418,7 +418,7 @@ }, { "key": "prompt.board", - "value": "E BOARD / BANK CARGO" + "value": "{0} BOARD / BANK CARGO" }, { "key": "prompt.inbound", @@ -426,7 +426,7 @@ }, { "key": "prompt.call", - "value": "E CALL EXTRACTION / 15s" + "value": "{0} CALL EXTRACTION / 15s" }, { "key": "world.extraction", @@ -535,6 +535,322 @@ { "key": "item.blueprint_reactive_guard", "value": "SCHEMATIC / REACTIVE SHIELD" + }, + { + "key": "settings.title", + "value": "SETTINGS" + }, + { + "key": "settings.help", + "value": "Changes are saved when you select Apply. Language changes immediately." + }, + { + "key": "settings.language", + "value": "Language" + }, + { + "key": "settings.audio", + "value": "Audio" + }, + { + "key": "settings.display", + "value": "Display" + }, + { + "key": "settings.controls", + "value": "Controls" + }, + { + "key": "settings.volume", + "value": "Master volume" + }, + { + "key": "settings.volumeHelp", + "value": "Adjust the volume of all game sounds. Set to 0% to mute." + }, + { + "key": "settings.percent", + "value": "{0}%" + }, + { + "key": "settings.multiplier", + "value": "{0}×" + }, + { + "key": "settings.quality", + "value": "Graphics quality" + }, + { + "key": "settings.quality.veryLow", + "value": "Very low" + }, + { + "key": "settings.quality.low", + "value": "Low" + }, + { + "key": "settings.quality.medium", + "value": "Medium" + }, + { + "key": "settings.quality.high", + "value": "High" + }, + { + "key": "settings.quality.veryHigh", + "value": "Very high" + }, + { + "key": "settings.quality.ultra", + "value": "Ultra" + }, + { + "key": "settings.resolution", + "value": "Resolution" + }, + { + "key": "settings.windowMode", + "value": "Window mode" + }, + { + "key": "settings.windowed", + "value": "Windowed" + }, + { + "key": "settings.fullscreen", + "value": "Fullscreen" + }, + { + "key": "settings.displayHelp", + "value": "After applying a display change, confirm it within 15 seconds or the previous display settings return." + }, + { + "key": "settings.display.confirm", + "value": "Keep these display settings?" + }, + { + "key": "settings.display.countdown", + "value": "The previous display settings return in {0} seconds." + }, + { + "key": "settings.display.keep", + "value": "Keep and save" + }, + { + "key": "settings.display.revert", + "value": "Revert" + }, + { + "key": "settings.display.reverted", + "value": "The previous display settings have been restored. Other edits are still pending." + }, + { + "key": "settings.mouse", + "value": "Mouse" + }, + { + "key": "settings.sensitivity", + "value": "Mouse sensitivity" + }, + { + "key": "settings.aimSensitivity", + "value": "Aim sensitivity multiplier" + }, + { + "key": "settings.bindings", + "value": "Key bindings" + }, + { + "key": "settings.bind.help", + "value": "Select a binding, then press a keyboard key or mouse button. Esc cancels capture and always works as a menu back key. Duplicate bindings are rejected." + }, + { + "key": "settings.bind.waiting", + "value": "Waiting for input" + }, + { + "key": "settings.bind.prompt", + "value": "Press a new key or mouse button for {0}. Press Esc to cancel." + }, + { + "key": "settings.bind.cancel", + "value": "Cancel binding" + }, + { + "key": "settings.bind.changed", + "value": "The binding for {0} has changed. Select Apply to save it." + }, + { + "key": "settings.bind.cancelled", + "value": "Binding cancelled; the previous key is unchanged." + }, + { + "key": "settings.bind.conflict", + "value": "This key is already assigned to {0}. Choose another key." + }, + { + "key": "settings.bind.reserved", + "value": "Esc is reserved for closing menus and cancelling input. Choose another key." + }, + { + "key": "settings.bind.unsupported", + "value": "Choose a keyboard key or mouse button." + }, + { + "key": "settings.reset", + "value": "Restore defaults" + }, + { + "key": "settings.resetHint", + "value": "Default settings and bindings are ready. Select Apply to save them." + }, + { + "key": "settings.back", + "value": "Back / discard edits" + }, + { + "key": "settings.apply", + "value": "Apply and save" + }, + { + "key": "settings.saved", + "value": "Settings saved." + }, + { + "key": "settings.saveFailed", + "value": "Settings could not be saved. Check available disk space and folder access, then try again." + }, + { + "key": "settings.action.move.up", + "value": "Move forward" + }, + { + "key": "settings.action.move.down", + "value": "Move backward" + }, + { + "key": "settings.action.move.left", + "value": "Move left" + }, + { + "key": "settings.action.move.right", + "value": "Move right" + }, + { + "key": "settings.action.fire", + "value": "Primary weapon" + }, + { + "key": "settings.action.aim", + "value": "Precision aim" + }, + { + "key": "settings.action.boost", + "value": "Boost" + }, + { + "key": "settings.action.jump", + "value": "Jump / lift" + }, + { + "key": "settings.action.view", + "value": "Switch perspective" + }, + { + "key": "settings.action.interact", + "value": "Interact" + }, + { + "key": "settings.action.inventory", + "value": "Open cargo hold" + }, + { + "key": "settings.action.cargo", + "value": "Launch cargo pod" + }, + { + "key": "settings.action.pause", + "value": "Pause" + }, + { + "key": "settings.action.secondary", + "value": "Secondary weapon" + }, + { + "key": "settings.action.guard", + "value": "Active defense" + }, + { + "key": "settings.key.mouseLeft", + "value": "Mouse left" + }, + { + "key": "settings.key.mouseRight", + "value": "Mouse right" + }, + { + "key": "settings.key.mouseMiddle", + "value": "Mouse middle" + }, + { + "key": "settings.key.mouseForward", + "value": "Mouse forward" + }, + { + "key": "settings.key.mouseBack", + "value": "Mouse back" + }, + { + "key": "settings.key.space", + "value": "Space" + }, + { + "key": "settings.key.leftShift", + "value": "Left Shift" + }, + { + "key": "settings.key.rightShift", + "value": "Right Shift" + }, + { + "key": "settings.action.reload", + "value": "Reload" + }, + { + "key": "ui.combat.nav", + "value": "SECTOR MAP" + }, + { + "key": "ui.combat.legend", + "value": "◇ Objective ○ Extraction" + }, + { + "key": "ui.combat.ammo", + "value": "MAGAZINE / UNLIMITED RESERVE" + }, + { + "key": "ui.combat.reload", + "value": "[{0}] Reload" + }, + { + "key": "ui.combat.loading", + "value": "Reloading {0:0.0}s" + }, + { + "key": "ui.combat.ready", + "value": "Ready" + }, + { + "key": "ui.combat.charging", + "value": "Cooldown {0:0.0}s" + }, + { + "key": "ui.combat.lowEnergy", + "value": "Insufficient energy" + }, + { + "key": "ui.combat.cargo", + "value": "Cargo {0} / 4" } ] } diff --git a/Assets/Game/Localization/Tables/zh-Hans.json b/Assets/Game/Localization/Tables/zh-Hans.json index 861fd8b..00d9fb5 100644 --- a/Assets/Game/Localization/Tables/zh-Hans.json +++ b/Assets/Game/Localization/Tables/zh-Hans.json @@ -50,11 +50,11 @@ }, { "key": "ui.home.moveHint", - "value": "WASD 移动 / 鼠标瞄准 / V 切换视角 / Shift 推进 / Space 跳跃与升空" + "value": "{0} 移动 / 鼠标瞄准 / {1} 切换视角 / {2} 推进 / {3} 跳跃与升空" }, { "key": "ui.home.fireHint", - "value": "鼠标左键 主武器 / 右键 精瞄 / Q 副武装 / F 护盾 / E 交互" + "value": "{0} 主武器 / {1} 精瞄 / {2} 副武装 / {3} 护盾 / {4} 交互" }, { "key": "ui.home.inventory", @@ -114,11 +114,11 @@ }, { "key": "ui.hud.podReady", - "value": "回收舱就绪 / Tab 选择货物" + "value": "回收舱就绪 / {0} 选择货物" }, { "key": "ui.hud.hint", - "value": "V 视角 Shift 推进 Space 升空 Q 副武装 F 护盾 E 交互 Tab 货仓 按住 G 发射回收舱 Esc 暂停" + "value": "{0} 视角 {1} 推进 {2} 升空 {3} 副武装 {4} 护盾 {5} 交互 {6} 货仓 按住 {7} 发射回收舱 {8} 暂停" }, { "key": "ui.hud.zone", @@ -182,7 +182,7 @@ }, { "key": "ui.cargo.help", - "value": "最多选择两组货物。关闭面板后原地按住 G 3 秒,发射回收舱。" + "value": "最多选择两组货物。关闭面板后原地按住 {0} 3 秒发射回收舱。" }, { "key": "ui.cargo.stack", @@ -318,7 +318,7 @@ }, { "key": "notice.range", - "value": "驾驶试验场 / V 切换视角,E 操作附近装置。" + "value": "驾驶试验场 / {0} 切换视角,{1} 操作附近设备。" }, { "key": "notice.enter", @@ -390,7 +390,7 @@ }, { "key": "notice.transportReady", - "value": "运输机已抵达 / 返回橙色平台并按 E 登机。" + "value": "接应运输机已抵达 / 返回橙色撤离坪,按 {0} 登机。" }, { "key": "notice.escortLost", @@ -398,7 +398,7 @@ }, { "key": "prompt.search", - "value": "E 搜索物资" + "value": "{0} 搜索物资" }, { "key": "prompt.relayOnline", @@ -406,11 +406,11 @@ }, { "key": "prompt.objective", - "value": "E 回收导航数据" + "value": "{0} 取回导航数据" }, { "key": "prompt.route", - "value": "E 选择下一站路线" + "value": "{0} 选择下一站路线" }, { "key": "prompt.routeLocked", @@ -418,7 +418,7 @@ }, { "key": "prompt.board", - "value": "E 登机 / 货物入库" + "value": "{0} 登机 / 带回随身物资" }, { "key": "prompt.inbound", @@ -426,7 +426,7 @@ }, { "key": "prompt.call", - "value": "E 呼叫撤离 / 等待 15 秒" + "value": "{0} 呼叫接应 / 15 秒" }, { "key": "world.extraction", @@ -535,6 +535,322 @@ { "key": "item.blueprint_reactive_guard", "value": "蓝图 / 反应护盾" + }, + { + "key": "settings.title", + "value": "设置" + }, + { + "key": "settings.help", + "value": "点击应用后保存设置,语言切换即时生效。" + }, + { + "key": "settings.language", + "value": "语言" + }, + { + "key": "settings.audio", + "value": "音频" + }, + { + "key": "settings.display", + "value": "画面" + }, + { + "key": "settings.controls", + "value": "操作与按键" + }, + { + "key": "settings.volume", + "value": "总音量" + }, + { + "key": "settings.volumeHelp", + "value": "调节游戏内所有声音的音量,设为 0% 即可静音。" + }, + { + "key": "settings.percent", + "value": "{0}%" + }, + { + "key": "settings.multiplier", + "value": "{0} 倍" + }, + { + "key": "settings.quality", + "value": "画质" + }, + { + "key": "settings.quality.veryLow", + "value": "极低" + }, + { + "key": "settings.quality.low", + "value": "低" + }, + { + "key": "settings.quality.medium", + "value": "中" + }, + { + "key": "settings.quality.high", + "value": "高" + }, + { + "key": "settings.quality.veryHigh", + "value": "极高" + }, + { + "key": "settings.quality.ultra", + "value": "最高" + }, + { + "key": "settings.resolution", + "value": "分辨率" + }, + { + "key": "settings.windowMode", + "value": "显示模式" + }, + { + "key": "settings.windowed", + "value": "窗口" + }, + { + "key": "settings.fullscreen", + "value": "全屏" + }, + { + "key": "settings.displayHelp", + "value": "更改分辨率或显示模式后,需要在 15 秒内确认,否则自动恢复上一次显示设置。" + }, + { + "key": "settings.display.confirm", + "value": "保留当前显示设置?" + }, + { + "key": "settings.display.countdown", + "value": "将在 {0} 秒后自动恢复上一次显示设置。" + }, + { + "key": "settings.display.keep", + "value": "保留并保存" + }, + { + "key": "settings.display.revert", + "value": "恢复原设置" + }, + { + "key": "settings.display.reverted", + "value": "已恢复上一次显示设置,其他修改仍待应用。" + }, + { + "key": "settings.mouse", + "value": "鼠标" + }, + { + "key": "settings.sensitivity", + "value": "鼠标灵敏度" + }, + { + "key": "settings.aimSensitivity", + "value": "精瞄灵敏度倍率" + }, + { + "key": "settings.bindings", + "value": "按键设置" + }, + { + "key": "settings.bind.help", + "value": "点击按键后,按下键盘按键或鼠标按钮进行改绑。Esc 取消改绑,并始终可用于返回菜单;重复按键会提示冲突。" + }, + { + "key": "settings.bind.waiting", + "value": "等待按键" + }, + { + "key": "settings.bind.prompt", + "value": "请为“{0}”按下新的键盘按键或鼠标按钮,按 Esc 取消。" + }, + { + "key": "settings.bind.cancel", + "value": "取消改绑" + }, + { + "key": "settings.bind.changed", + "value": "已更改“{0}”的按键,点击应用后保存。" + }, + { + "key": "settings.bind.cancelled", + "value": "已取消改绑,保留原按键。" + }, + { + "key": "settings.bind.conflict", + "value": "该按键已用于“{0}”,请选择其他按键。" + }, + { + "key": "settings.bind.reserved", + "value": "Esc 用于关闭菜单和取消输入,请选择其他按键。" + }, + { + "key": "settings.bind.unsupported", + "value": "请选择键盘按键或鼠标按钮。" + }, + { + "key": "settings.reset", + "value": "恢复默认" + }, + { + "key": "settings.resetHint", + "value": "已恢复默认设置与按键,点击应用后保存。" + }, + { + "key": "settings.back", + "value": "返回 / 放弃修改" + }, + { + "key": "settings.apply", + "value": "应用并保存" + }, + { + "key": "settings.saved", + "value": "设置已保存。" + }, + { + "key": "settings.saveFailed", + "value": "设置保存失败,请检查磁盘空间与目录写入权限后重试。" + }, + { + "key": "settings.action.move.up", + "value": "向前移动" + }, + { + "key": "settings.action.move.down", + "value": "向后移动" + }, + { + "key": "settings.action.move.left", + "value": "向左移动" + }, + { + "key": "settings.action.move.right", + "value": "向右移动" + }, + { + "key": "settings.action.fire", + "value": "主武器" + }, + { + "key": "settings.action.aim", + "value": "精瞄" + }, + { + "key": "settings.action.boost", + "value": "推进" + }, + { + "key": "settings.action.jump", + "value": "跳跃 / 升空" + }, + { + "key": "settings.action.view", + "value": "切换视角" + }, + { + "key": "settings.action.interact", + "value": "交互" + }, + { + "key": "settings.action.inventory", + "value": "打开货仓" + }, + { + "key": "settings.action.cargo", + "value": "发射回收舱" + }, + { + "key": "settings.action.pause", + "value": "暂停" + }, + { + "key": "settings.action.secondary", + "value": "副武装" + }, + { + "key": "settings.action.guard", + "value": "主动防护" + }, + { + "key": "settings.key.mouseLeft", + "value": "鼠标左键" + }, + { + "key": "settings.key.mouseRight", + "value": "鼠标右键" + }, + { + "key": "settings.key.mouseMiddle", + "value": "鼠标中键" + }, + { + "key": "settings.key.mouseForward", + "value": "鼠标前侧键" + }, + { + "key": "settings.key.mouseBack", + "value": "鼠标后侧键" + }, + { + "key": "settings.key.space", + "value": "空格" + }, + { + "key": "settings.key.leftShift", + "value": "左 Shift" + }, + { + "key": "settings.key.rightShift", + "value": "右 Shift" + }, + { + "key": "settings.action.reload", + "value": "换弹" + }, + { + "key": "ui.combat.nav", + "value": "区域地图" + }, + { + "key": "ui.combat.legend", + "value": "◇ 目标 ○ 撤离点" + }, + { + "key": "ui.combat.ammo", + "value": "弹匣 / 无限备弹" + }, + { + "key": "ui.combat.reload", + "value": "[{0}] 换弹" + }, + { + "key": "ui.combat.loading", + "value": "换弹中 {0:0.0} 秒" + }, + { + "key": "ui.combat.ready", + "value": "就绪" + }, + { + "key": "ui.combat.charging", + "value": "冷却 {0:0.0} 秒" + }, + { + "key": "ui.combat.lowEnergy", + "value": "能源不足" + }, + { + "key": "ui.combat.cargo", + "value": "货舱 {0} / 4" } ] } diff --git a/Assets/Game/Scripts/Equipment/EquipmentDefinition.cs b/Assets/Game/Scripts/Equipment/EquipmentDefinition.cs index 040b919..d1ec3c1 100644 --- a/Assets/Game/Scripts/Equipment/EquipmentDefinition.cs +++ b/Assets/Game/Scripts/Equipment/EquipmentDefinition.cs @@ -12,6 +12,8 @@ namespace Ironspore public float mass = 2; public float power = 2; public float damage = 15; + [Min(1)] public int magazineSize = 36; + [Min(.1f)] public float reloadSeconds = 2; public float fireRate = 6; public float speedFactor = 1; public bool guided; diff --git a/Assets/Game/Scripts/Localization/GameLocalization.cs b/Assets/Game/Scripts/Localization/GameLocalization.cs index 1c5605c..58a9456 100644 --- a/Assets/Game/Scripts/Localization/GameLocalization.cs +++ b/Assets/Game/Scripts/Localization/GameLocalization.cs @@ -7,7 +7,7 @@ namespace Ironspore { public static class GameLocalization { - public const string PreferenceKey = "settings.locale"; + public static string PreferenceKey => GameDataPaths.PreferenceKey("settings.locale"); public const string FallbackCode = "en"; public static event Action Changed; public static string CurrentCode { get; private set; } = FallbackCode; diff --git a/Assets/Game/Scripts/Mechs/MechPilot.cs b/Assets/Game/Scripts/Mechs/MechPilot.cs index 2b49b8f..407cdb2 100644 --- a/Assets/Game/Scripts/Mechs/MechPilot.cs +++ b/Assets/Game/Scripts/Mechs/MechPilot.cs @@ -26,6 +26,11 @@ namespace Ironspore { get; private set; } + public int Rounds { get; private set; } + public float ReloadRemaining { get; private set; } + public float SecondaryCooldown => Mathf.Max(0, sideCooldown); + public float Heading => yaw; + public EquipmentDefinition Primary { get; private set; } GameSession game; CharacterController motor; Camera view; @@ -67,6 +72,13 @@ namespace Ironspore } public void ApplyLoadout() { + var primary = game.Part(0, game.Run.equipment[0]); + if (Primary != primary) + { + Primary = primary; + Rounds = primary.magazineSize; + ReloadRemaining = 0; + } visual.localScale = game.chassis[game.Run.chassis].modelScale; } public void RequireFreshTrigger() @@ -79,6 +91,12 @@ namespace Ironspore return; var frame = game.chassis[game.Run.chassis]; float dt = Time.deltaTime; + if (ReloadRemaining > 0) + { + ReloadRemaining = Mathf.Max(0, ReloadRemaining - dt); + if (ReloadRemaining == 0) + Rounds = Primary.magazineSize; + } boostCooldown -= dt; shotCooldown -= dt; sideCooldown -= dt; @@ -88,8 +106,10 @@ namespace Ironspore if (!game.MenuOpen) { Vector2 look = game.Action("Look").ReadValue(); - yaw += look.x * .105f; - pitch = Mathf.Clamp(pitch - look.y * .105f, -72, 75); + float sensitivity = .105f * game.Settings.LookSensitivity + * (game.Action("Aim").IsPressed() ? game.Settings.AimSensitivity : 1); + yaw += look.x * sensitivity; + pitch = Mathf.Clamp(pitch - look.y * sensitivity, -72, 75); if (game.Action("View").WasPressedThisFrame()) FirstPerson = !FirstPerson; if (game.Action("Guard").WasPressedThisFrame() && Energy >= 20) @@ -139,8 +159,14 @@ namespace Ironspore { if (!game.Action("Fire").IsPressed()) freshTrigger = false; - if (!freshTrigger && game.Action("Fire").IsPressed() && shotCooldown <= 0) + if (game.Action("Reload").WasPressedThisFrame() && Rounds < Primary.magazineSize && ReloadRemaining == 0) + ReloadRemaining = Primary.reloadSeconds; + if (!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) { Energy -= 15; diff --git a/Assets/Game/Scripts/Runs/GameSession.cs b/Assets/Game/Scripts/Runs/GameSession.cs index b9a6394..b278276 100644 --- a/Assets/Game/Scripts/Runs/GameSession.cs +++ b/Assets/Game/Scripts/Runs/GameSession.cs @@ -42,6 +42,7 @@ namespace Ironspore { get; private set; } + public SettingsService Settings { get; private set; } public readonly List SelectedCargo = new List(); public bool MenuOpen { get; private set; } = true; public bool Paused @@ -76,9 +77,11 @@ namespace Ironspore Active = this; GameLocalization.Initialize(localization); Application.targetFrameRate = 120; - mainStore = new ProfileStore(Path.Combine(Application.persistentDataPath, "Ironspore", "profile.json")); + mainStore = new ProfileStore(Path.Combine(GameDataPaths.DirectoryPath, "profile.json")); Store = mainStore; Input = Instantiate(inputTemplate); + var defaults = SettingsService.RuntimeDefaults(); + Settings = new SettingsService(new SettingsStore(Path.Combine(GameDataPaths.DirectoryPath, "settings.json"), defaults), Input, defaults); Input.Enable(); World = gameObject.AddComponent(); Hud = gameObject.AddComponent(); @@ -148,7 +151,7 @@ namespace Ironspore public void StartTrial() { Trial = true; - Store = new ProfileStore(Path.Combine(Application.persistentDataPath, "Ironspore", "range.json")); + Store = new ProfileStore(Path.Combine(GameDataPaths.DirectoryPath, "range.json")); Store.Begin(SelectedChassis, InitialEquipment, chassis[SelectedChassis].armor, 1739); EnterField(); } @@ -169,7 +172,10 @@ namespace Ironspore Pilot = World.Player; SetMenu(false); Hud.ShowField(); - Notify(Trial ? "notice.range" : "notice.enter"); + if (Trial) + Notify("notice.range", Settings.Bindings.Display("View"), Settings.Bindings.Display("Interact")); + else + Notify("notice.enter"); } public void SetMenu(bool open) { @@ -209,6 +215,8 @@ namespace Ironspore { if (Time.unscaledTime > noticeUntil) noticeMessage = null; + if (Hud && (Hud.SettingsOpen || Hud.MenuInputConsumedThisFrame)) + return; if (!InField || !Pilot || ending) return; if (Action("Pause").WasPressedThisFrame()) @@ -416,6 +424,11 @@ namespace Ironspore } void OnApplicationFocus(bool focus) { + if (!focus && Hud && Hud.SettingsOpen) + { + Hud.CancelSettingsInput(); + return; + } if (!focus && InField && Hud) { Paused = true; @@ -434,6 +447,7 @@ namespace Ironspore } void OnDestroy() { + Settings?.Dispose(); if (Input) Destroy(Input); Time.timeScale = 1; diff --git a/Assets/Game/Scripts/Runs/WarzoneDirector.cs b/Assets/Game/Scripts/Runs/WarzoneDirector.cs index 045866a..c12c5e9 100644 --- a/Assets/Game/Scripts/Runs/WarzoneDirector.cs +++ b/Assets/Game/Scripts/Runs/WarzoneDirector.cs @@ -21,6 +21,13 @@ namespace Ironspore { get; private set; } + Transform relay, onwardGate, terminalTarget; + public Transform NavigationTarget => game.Run.routeCompleted ? null : + !game.Trial && game.Run.stage == 4 ? terminalTarget : + game.Run.objectiveDone ? onwardGate : relay; + public Vector3 ExtractionPosition => extractionPosition; + readonly System.Collections.Generic.List cover = new System.Collections.Generic.List(); + public System.Collections.Generic.IReadOnlyList Cover => cover; Transform root; GameSession game; Vector3 extractionPosition = new Vector3(-19, 0, -8); @@ -39,6 +46,8 @@ namespace Ironspore TransportReady = false; Focused = 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); @@ -100,6 +109,7 @@ namespace Ironspore { float x = (i % 2 == 0 ? -1 : 1) * (8 + (i % 3) * 5); float z = -10 + i * 6; + cover.Add(new Rect(x - 2.75f, z - 1.5f, 5.5f, 3)); var color = i % 2 == 0 ? new Color(.22f, .34f, .37f) : new Color(.45f, .26f, .15f); WorldFactory.Box("FreightContainer", new Vector3(x, 1.1f, z), new Vector3(5.5f, 2.2f, 3), color, root); for (int rib = 0; rib < 5; rib++) @@ -129,10 +139,12 @@ namespace Ironspore } var objective = WorldFactory.Box("NavigationRelay", new Vector3(0, 1.5f, 23), new Vector3(2.2f, 3, 1.6f), new Color(.21f, .33f, .36f), root); WorldFactory.Box("RelayScreen", new Vector3(0, 2.4f, 22.14f), new Vector3(1.5f, .65f, .1f), new Color(.1f, .9f, .83f), root, false, true); + relay = objective.transform; var objectiveAction = objective.AddComponent(); objectiveAction.kind = 1; objectiveAction.RefreshLabel(); var onward = WorldFactory.Cylinder("OnwardGate", new Vector3(0, .05f, 36), new Vector3(7, .1f, 7), new Color(.3f, .6f, .65f), root, false); + onwardGate = onward.transform; var onwardAction = onward.AddComponent(); onwardAction.kind = 2; onwardAction.RefreshLabel(); @@ -158,6 +170,7 @@ namespace Ironspore if (run.branch == 1 && !run.routeCompleted) { var cart = WorldFactory.Box("EscortEngineer", new Vector3(0, 1.3f, 0), new Vector3(3, 2.6f, 4), new Color(.2f, .55f, .5f), root); + terminalTarget = cart.transform; Escort = cart.AddComponent(); Escort.Initialize(game); } @@ -169,6 +182,8 @@ namespace Ironspore obj.transform.SetParent(root); obj.transform.position = pos; obj.layer = 9; + if (type == 3) + terminalTarget = obj.transform; obj.AddComponent().Initialize(game, type, game.mechModel, root); } public void CallExtraction() @@ -199,7 +214,7 @@ 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); - game.Notify("notice.transportReady"); + game.Notify("notice.transportReady", game.Settings.Bindings.Display("Interact")); GameAudio.Cue(950, .3f); } } @@ -236,13 +251,14 @@ namespace Ironspore get { var g = GameSession.Active; + string interact = g.Settings.Bindings.Display("Interact"); if (kind == 0) - return GameLocalization.Text("prompt.search"); + return GameLocalization.Text("prompt.search", interact); if (kind == 1) - return GameLocalization.Text(g.Run.objectiveDone ? "prompt.relayOnline" : "prompt.objective"); + return GameLocalization.Text(g.Run.objectiveDone ? "prompt.relayOnline" : "prompt.objective", interact); if (kind == 2) - return GameLocalization.Text(g.Run.objectiveDone ? "prompt.route" : "prompt.routeLocked"); - return GameLocalization.Text(g.World.TransportReady ? "prompt.board" : g.World.ExtractionRemaining >= 0 ? "prompt.inbound" : "prompt.call"); + return GameLocalization.Text(g.Run.objectiveDone ? "prompt.route" : "prompt.routeLocked", interact); + return GameLocalization.Text(g.World.TransportReady ? "prompt.board" : g.World.ExtractionRemaining >= 0 ? "prompt.inbound" : "prompt.call", interact); } } public void RefreshLabel() diff --git a/Assets/Game/Scripts/Save/GameDataPaths.cs b/Assets/Game/Scripts/Save/GameDataPaths.cs new file mode 100644 index 0000000..54016c7 --- /dev/null +++ b/Assets/Game/Scripts/Save/GameDataPaths.cs @@ -0,0 +1,42 @@ +using System; +using System.IO; +using UnityEngine; + +namespace Ironspore +{ + public static class GameDataPaths + { + public static string DirectoryPath + { + get + { + string isolated = CommandLineDirectory(); + if (isolated != null) + return isolated; +#if UNITY_EDITOR + return Path.GetFullPath(Path.Combine(Application.dataPath, "../UserSettings/Ironspore")); +#else + return Path.Combine(Application.persistentDataPath, "Ironspore"); +#endif + } + } + + public static string PreferenceKey(string key) + { + string isolated = CommandLineDirectory(); +#if UNITY_EDITOR + isolated = isolated ?? DirectoryPath; +#endif + return isolated == null ? key : "ironspore." + Hash128.Compute(isolated.ToLowerInvariant()) + "." + key; + } + + static string CommandLineDirectory() + { + string[] args = Environment.GetCommandLineArgs(); + for (int i = 0; i + 1 < args.Length; i++) + if (args[i] == "-ironspore-data-path" && Path.IsPathRooted(args[i + 1])) + return Path.GetFullPath(args[i + 1]); + return null; + } + } +} diff --git a/Assets/Game/Scripts/Save/GameDataPaths.cs.meta b/Assets/Game/Scripts/Save/GameDataPaths.cs.meta new file mode 100644 index 0000000..92f7c98 --- /dev/null +++ b/Assets/Game/Scripts/Save/GameDataPaths.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 519f858dabbde1042a2c7fba13058b93 \ No newline at end of file diff --git a/Assets/Game/Scripts/Settings.meta b/Assets/Game/Scripts/Settings.meta new file mode 100644 index 0000000..01068c7 --- /dev/null +++ b/Assets/Game/Scripts/Settings.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 313dde83e009ad347a3a02ebacbde7d4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Game/Scripts/Settings/GameSettingsData.cs b/Assets/Game/Scripts/Settings/GameSettingsData.cs new file mode 100644 index 0000000..9f08e58 --- /dev/null +++ b/Assets/Game/Scripts/Settings/GameSettingsData.cs @@ -0,0 +1,43 @@ +using System; +using UnityEngine; + +namespace Ironspore +{ + [Serializable] + public sealed class GameSettingsData + { + public int version = 1; + public float masterVolume = 1; + public int qualityLevel = 2; + public int resolutionWidth = 1280; + public int resolutionHeight = 720; + public bool fullscreen; + public float mouseSensitivity = 1; + public float aimSensitivity = 1; + public string bindingOverrides = ""; + + public GameSettingsData Copy() => (GameSettingsData)MemberwiseClone(); + + public GameSettingsData Normalize(int qualityCount) + { + var copy = Copy(); + copy.masterVolume = FiniteClamp(masterVolume, 0, 1, 1); + copy.mouseSensitivity = FiniteClamp(mouseSensitivity, .1f, 5, 1); + copy.aimSensitivity = FiniteClamp(aimSensitivity, .1f, 2, 1); + copy.qualityLevel = Mathf.Clamp(qualityLevel, 0, Mathf.Max(0, qualityCount - 1)); + if (resolutionWidth < 640 || resolutionWidth > 16384 || resolutionHeight < 360 || resolutionHeight > 16384) + { + copy.resolutionWidth = 1280; + copy.resolutionHeight = 720; + } + copy.bindingOverrides = bindingOverrides ?? ""; + return copy; + } + + public bool SameDisplay(GameSettingsData other) => resolutionWidth == other.resolutionWidth + && resolutionHeight == other.resolutionHeight && fullscreen == other.fullscreen; + + static float FiniteClamp(float value, float min, float max, float fallback) => + float.IsNaN(value) || float.IsInfinity(value) ? fallback : Mathf.Clamp(value, min, max); + } +} diff --git a/Assets/Game/Scripts/Settings/GameSettingsData.cs.meta b/Assets/Game/Scripts/Settings/GameSettingsData.cs.meta new file mode 100644 index 0000000..925c089 --- /dev/null +++ b/Assets/Game/Scripts/Settings/GameSettingsData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7a1196ee2b7e1734abda51de9ef9c0c4 \ No newline at end of file diff --git a/Assets/Game/Scripts/Settings/InputBindingService.cs b/Assets/Game/Scripts/Settings/InputBindingService.cs new file mode 100644 index 0000000..17c0ea4 --- /dev/null +++ b/Assets/Game/Scripts/Settings/InputBindingService.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.InputSystem; + +namespace Ironspore +{ + public sealed class BindingSlot + { + public readonly InputAction Action; + public readonly int Index; + public readonly string LabelKey; + public BindingSlot(InputAction action, int index) + { + Action = action; + Index = index; + LabelKey = "settings.action." + action.name.ToLowerInvariant() + + (action.bindings[index].isPartOfComposite ? "." + action.bindings[index].name.ToLowerInvariant() : ""); + } + } + + public enum BindingChange { Success, Cancelled, Conflict, Reserved, Unsupported } + + public sealed class InputBindingService : IDisposable + { + readonly InputActionAsset input; + readonly List slots = new List(); + readonly List enabledActions = new List(); + InputActionRebindingExtensions.RebindingOperation operation; + BindingSlot activeSlot; + Action onCompleted; + public IReadOnlyList Slots => slots; + public bool IsRebinding => operation != null; + public int LastRebindFrame { get; private set; } = -1; + // Input callbacks can run before Unity advances frameCount for Update. + public bool SuppressMenuInput => IsRebinding || (LastRebindFrame >= 0 && Time.frameCount <= LastRebindFrame + 1); + + public InputBindingService(InputActionAsset input) + { + this.input = input; + foreach (var action in input.FindActionMap("Pilot", true).actions) + for (int i = 0; i < action.bindings.Count; i++) + if (action.name != "Look" && !action.bindings[i].isComposite) + slots.Add(new BindingSlot(action, i)); + } + + public string Save() => input.SaveBindingOverridesAsJson(); + + public bool TryLoad(string json) + { + Cancel(); + string previous = Save(); + try + { + input.RemoveAllBindingOverrides(); + if (!string.IsNullOrEmpty(json)) + input.LoadBindingOverridesFromJson(json); + // Older profiles may already use R. Preserve their binding and move Reload. + var reload = slots.Find(slot => slot.Action.name == "Reload"); + if (reload != null && !reload.Action.bindings[reload.Index].hasOverrides && + Check(reload, reload.Action.bindings[reload.Index].path, out _) == BindingChange.Conflict) + { + foreach (char letter in "tyuiohjklbnmpxczaqwsdefgvr") + if (Rebind(reload, "/" + letter, out _) == BindingChange.Success) + break; + } + foreach (var map in input.actionMaps) + foreach (var action in map.actions) + foreach (var binding in action.bindings) + if (binding.hasOverrides && (action.name == "Look" || binding.isComposite + || binding.overrideInteractions != null || binding.overrideProcessors != null)) + throw new ArgumentException("Only button binding paths can be overridden."); + foreach (var slot in slots) + if (slot.Action.bindings[slot.Index].hasOverrides && Check(slot, slot.Action.bindings[slot.Index].effectivePath, out _) != BindingChange.Success) + throw new ArgumentException("Invalid or conflicting binding override."); + return true; + } + catch (Exception) + { + input.RemoveAllBindingOverrides(); + input.LoadBindingOverridesFromJson(previous); + return false; + } + } + + public void Reset() { Cancel(); input.RemoveAllBindingOverrides(); } + + public BindingChange Check(BindingSlot target, string path, out BindingSlot conflict) + { + conflict = null; + if (string.Equals(path, "/escape", StringComparison.OrdinalIgnoreCase) && target.Action.name != "Pause") + return BindingChange.Reserved; + if (!Supported(path)) + return BindingChange.Unsupported; + foreach (var slot in slots) + if ((slot.Action != target.Action || slot.Index != target.Index) + && string.Equals(slot.Action.bindings[slot.Index].effectivePath, path, StringComparison.OrdinalIgnoreCase)) + { + conflict = slot; + return BindingChange.Conflict; + } + return BindingChange.Success; + } + + public BindingChange Rebind(BindingSlot slot, string path, out BindingSlot conflict) + { + var result = Check(slot, path, out conflict); + if (result == BindingChange.Success) + slot.Action.ApplyBindingOverride(slot.Index, path); + return result; + } + + public void Begin(BindingSlot slot, Action completed) + { + Cancel(); + activeSlot = slot; + onCompleted = completed; + enabledActions.Clear(); + foreach (var map in input.actionMaps) + foreach (var action in map.actions) + if (action.enabled) + enabledActions.Add(action); + input.Disable(); + BindingChange result = BindingChange.Cancelled; + BindingSlot conflict = null; + operation = slot.Action.PerformInteractiveRebinding(slot.Index) + .WithExpectedControlType("Button") + .WithControlsHavingToMatchPath("") + .WithControlsHavingToMatchPath("") + .WithControlsExcluding("/position") + .WithControlsExcluding("/delta") + .WithControlsExcluding("/scroll") + // Let the UI adapter distinguish a cancel-button click from a left-button binding. + .WithControlsExcluding("/leftButton") + .WithControlsExcluding("/anyKey") + .WithControlsExcluding("/shift") + .WithControlsExcluding("/ctrl") + .WithControlsExcluding("/alt") + .WithCancelingThrough("/escape") + .OnMatchWaitForAnother(0) + .OnApplyBinding((_, path) => result = Rebind(slot, path, out conflict)) + .OnCancel(_ => Finish(BindingChange.Cancelled, null)) + .OnComplete(_ => Finish(result, conflict)); + operation.Start(); + } + + // UI Toolkit also delivers keyboard/pointer events when a platform does not forward + // synthetic or accessibility input as raw device events. Both routes use the same validator. + public bool CapturePath(string path) + { + if (!IsRebinding) + return false; + var completed = onCompleted; + onCompleted = null; + var result = Rebind(activeSlot, path, out var conflict); + Cancel(); + completed?.Invoke(result, conflict); + return true; + } + + void Finish(BindingChange result, BindingSlot conflict) + { + var finished = operation; + var completed = onCompleted; + operation = null; + onCompleted = null; + activeSlot = null; + finished?.Dispose(); + foreach (var action in enabledActions) + action.Enable(); + enabledActions.Clear(); + LastRebindFrame = Time.frameCount; + completed?.Invoke(result, conflict); + } + + public void Cancel() => operation?.Cancel(); + public void Dispose() => Cancel(); + + static bool Supported(string path) + { + if (string.IsNullOrEmpty(path)) + return false; + if (path.StartsWith("/", StringComparison.OrdinalIgnoreCase)) + { + string name = path.Substring(11); + foreach (var keyControl in InputSystem.LoadLayout("Keyboard").controls) + if (keyControl.layout.ToString() == "Key" && string.Equals(keyControl.name.ToString(), name, StringComparison.OrdinalIgnoreCase)) + return true; + return false; + } + if (!path.StartsWith("/", StringComparison.OrdinalIgnoreCase)) + return false; + string control = path.Substring(8).ToLowerInvariant(); + return control == "leftbutton" || control == "rightbutton" || control == "middlebutton" + || control == "forwardbutton" || control == "backbutton"; + } + + public string Display(BindingSlot slot) + { + string path = slot.Action.bindings[slot.Index].effectivePath; + string special = path?.ToLowerInvariant(); + switch (special) + { + case "/leftbutton": return GameLocalization.Text("settings.key.mouseLeft"); + case "/rightbutton": return GameLocalization.Text("settings.key.mouseRight"); + case "/middlebutton": return GameLocalization.Text("settings.key.mouseMiddle"); + case "/forwardbutton": return GameLocalization.Text("settings.key.mouseForward"); + case "/backbutton": return GameLocalization.Text("settings.key.mouseBack"); + case "/space": return GameLocalization.Text("settings.key.space"); + case "/leftshift": return GameLocalization.Text("settings.key.leftShift"); + case "/rightshift": return GameLocalization.Text("settings.key.rightShift"); + } + return slot.Action.GetBindingDisplayString(slot.Index); + } + + public string Display(string actionName) + { + var names = new List(); + foreach (var slot in slots) + if (slot.Action.name == actionName) + names.Add(Display(slot)); + return string.Join(" / ", names); + } + } +} diff --git a/Assets/Game/Scripts/Settings/InputBindingService.cs.meta b/Assets/Game/Scripts/Settings/InputBindingService.cs.meta new file mode 100644 index 0000000..804ecf9 --- /dev/null +++ b/Assets/Game/Scripts/Settings/InputBindingService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5338ce6eea517ea409e49e4303af1a00 \ No newline at end of file diff --git a/Assets/Game/Scripts/Settings/SettingsService.cs b/Assets/Game/Scripts/Settings/SettingsService.cs new file mode 100644 index 0000000..d7fe82e --- /dev/null +++ b/Assets/Game/Scripts/Settings/SettingsService.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.InputSystem; + +namespace Ironspore +{ + public enum SettingsApplyResult { Saved, ConfirmDisplay, Failed } + + // Runtime settings orchestration; UI owns its draft, while the store owns durable snapshots. + public sealed class SettingsService : IDisposable + { + readonly SettingsStore store; + readonly GameSettingsData defaults; + readonly float initialVolume; + readonly int initialQuality; + readonly Func clock; + GameSettingsData current, preview; + float displayDeadline; + public InputBindingService Bindings { get; } + public GameSettingsData Current => current.Copy(); + public GameSettingsData Defaults => defaults.Copy(); + public bool ConfirmingDisplay => preview != null; + public int ConfirmationSeconds => Mathf.Max(0, Mathf.CeilToInt(displayDeadline - clock())); + public string Error => store.Error; + public float LookSensitivity => current.mouseSensitivity; + public float AimSensitivity => current.aimSensitivity; + + public SettingsService(SettingsStore store, InputActionAsset input, GameSettingsData defaults, Func clock = null) + { + this.store = store; + this.defaults = defaults.Copy(); + this.clock = clock ?? (() => Time.realtimeSinceStartup); + initialVolume = AudioListener.volume; + initialQuality = QualitySettings.GetQualityLevel(); + Bindings = new InputBindingService(input); + current = store.Data.Normalize(QualitySettings.names.Length); + if (!Bindings.TryLoad(current.bindingOverrides)) + Debug.LogWarning("Invalid input overrides were ignored; using default controls."); + current.bindingOverrides = Bindings.Save(); + ApplyRuntime(current, true); + } + + public static GameSettingsData RuntimeDefaults() => new GameSettingsData + { + qualityLevel = Mathf.Min(2, QualitySettings.names.Length - 1), + resolutionWidth = Application.isEditor ? 1280 : Mathf.Max(640, Screen.width), + resolutionHeight = Application.isEditor ? 720 : Mathf.Max(360, Screen.height), + fullscreen = !Application.isEditor && Screen.fullScreen + }; + + public List Resolutions() + { + var sizes = new List(); + foreach (var resolution in Screen.resolutions) + AddSize(sizes, resolution.width, resolution.height); + AddSize(sizes, 1280, 720); + AddSize(sizes, 1600, 900); + AddSize(sizes, 1920, 1080); + AddSize(sizes, current.resolutionWidth, current.resolutionHeight); + sizes.Sort((a, b) => a.x == b.x ? a.y.CompareTo(b.y) : a.x.CompareTo(b.x)); + return sizes; + } + + static void AddSize(List sizes, int width, int height) + { + var size = new Vector2Int(width, height); + if (width >= 640 && height >= 360 && !sizes.Contains(size)) + sizes.Add(size); + } + + public SettingsApplyResult Apply(GameSettingsData draft) + { + CancelDisplay(); + var candidate = draft.Normalize(QualitySettings.names.Length); + candidate.bindingOverrides = Bindings.Save(); + if (!candidate.SameDisplay(current)) + { + preview = candidate; + displayDeadline = clock() + 15; + ApplyRuntime(preview, true); + return SettingsApplyResult.ConfirmDisplay; + } + return Save(candidate) ? SettingsApplyResult.Saved : SettingsApplyResult.Failed; + } + + public bool ConfirmDisplay() + { + if (preview == null) + return false; + var candidate = preview; + preview = null; + if (Save(candidate)) + return true; + ApplyRuntime(current, true); + Bindings.TryLoad(current.bindingOverrides); + return false; + } + + bool Save(GameSettingsData candidate) + { + if (!store.Save(candidate)) + return false; + current = candidate.Copy(); + ApplyRuntime(current, false); + return true; + } + + public bool Tick() + { + if (!ConfirmingDisplay || clock() < displayDeadline) + return false; + CancelDisplay(); + return true; + } + + public void CancelDisplay() + { + if (preview == null) + return; + preview = null; + ApplyRuntime(current, true); + } + + public void DiscardDraft() + { + Bindings.Cancel(); + CancelDisplay(); + Bindings.TryLoad(current.bindingOverrides); + } + + void ApplyRuntime(GameSettingsData data, bool display) + { + AudioListener.volume = data.masterVolume; + if (QualitySettings.GetQualityLevel() != data.qualityLevel) + QualitySettings.SetQualityLevel(data.qualityLevel, true); + if (display && !Application.isEditor) + Screen.SetResolution(data.resolutionWidth, data.resolutionHeight, + data.fullscreen ? FullScreenMode.ExclusiveFullScreen : FullScreenMode.Windowed); + } + + public void Dispose() + { + DiscardDraft(); + Bindings.Dispose(); +#if UNITY_EDITOR + AudioListener.volume = initialVolume; + QualitySettings.SetQualityLevel(initialQuality, true); +#endif + } + } +} diff --git a/Assets/Game/Scripts/Settings/SettingsService.cs.meta b/Assets/Game/Scripts/Settings/SettingsService.cs.meta new file mode 100644 index 0000000..3eecd9c --- /dev/null +++ b/Assets/Game/Scripts/Settings/SettingsService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2ee4518453bf9384faa13484f6751b4e \ No newline at end of file diff --git a/Assets/Game/Scripts/Settings/SettingsStore.cs b/Assets/Game/Scripts/Settings/SettingsStore.cs new file mode 100644 index 0000000..eb61ae8 --- /dev/null +++ b/Assets/Game/Scripts/Settings/SettingsStore.cs @@ -0,0 +1,65 @@ +using System; +using System.IO; +using System.Text; +using UnityEngine; + +namespace Ironspore +{ + // Settings have their own version and file; progression and recovery receipts stay in ProfileStore. + public sealed class SettingsStore + { + readonly string path; + readonly GameSettingsData defaults; + public GameSettingsData Data { get; private set; } + public string Error { get; private set; } + + public SettingsStore(string path, GameSettingsData defaults = null) + { + this.path = path; + this.defaults = (defaults ?? new GameSettingsData()).Copy(); + Data = Load(path) ?? Load(path + ".bak") ?? this.defaults.Copy(); + } + + GameSettingsData Load(string file) + { + try + { + if (!File.Exists(file)) + return null; + var data = defaults.Copy(); + JsonUtility.FromJsonOverwrite(File.ReadAllText(file), data); + return data.version == 1 ? data : null; + } + catch (Exception) { return null; } + } + + public bool Save(GameSettingsData candidate) + { + if (candidate == null || candidate.version != 1) + { + Error = "Unsupported settings version."; + return false; + } + try + { + var snapshot = candidate.Copy(); + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))); + string temp = path + ".tmp"; + using (var stream = new FileStream(temp, FileMode.Create, FileAccess.Write, FileShare.None)) + { + byte[] bytes = Encoding.UTF8.GetBytes(JsonUtility.ToJson(snapshot, true)); + stream.Write(bytes, 0, bytes.Length); + stream.Flush(true); + } + if (File.Exists(path)) + File.Replace(temp, path, path + ".bak"); + else + File.Move(temp, path); + Data = snapshot; + Error = null; + return true; + } + catch (Exception ex) { Error = ex.Message; return false; } + } + } +} diff --git a/Assets/Game/Scripts/Settings/SettingsStore.cs.meta b/Assets/Game/Scripts/Settings/SettingsStore.cs.meta new file mode 100644 index 0000000..6047682 --- /dev/null +++ b/Assets/Game/Scripts/Settings/SettingsStore.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 95681a73f0c68c6488b2e98c655d67f3 \ No newline at end of file diff --git a/Assets/Game/Scripts/UI/CombatInstruments.cs b/Assets/Game/Scripts/UI/CombatInstruments.cs new file mode 100644 index 0000000..0b1f9c5 --- /dev/null +++ b/Assets/Game/Scripts/UI/CombatInstruments.cs @@ -0,0 +1,133 @@ +using UnityEngine; +using UnityEngine.UIElements; + +namespace Ironspore +{ + // Field-only instruments. Weapon state and navigation targets belong to the simulation. + public sealed class CombatInstruments + { + readonly VisualElement field, map; + readonly GameSession game; + readonly Label primary, rounds, reload, secondary, cargo; + readonly string reloadHint; + float nextMapUpdate; + int displayedRounds = -1; + + public CombatInstruments(VisualElement field, VisualElement vitals, GameSession game) + { + this.field = field; + this.game = game; + var nav = Panel("combat-nav"); + Caption(nav, GameLocalization.Text("ui.combat.nav") + " N ↑", "combat-kicker"); + map = new VisualElement(); + map.AddToClassList("combat-map"); + nav.Add(map); + map.generateVisualContent += DrawMap; + Caption(nav, GameLocalization.Text("ui.combat.legend"), "combat-small"); + + var weapon = Panel("combat-weapon"); + primary = Caption(weapon, "", "combat-primary"); + Caption(weapon, GameLocalization.Text("ui.combat.ammo"), "combat-kicker"); + var row = new VisualElement(); + row.AddToClassList("combat-rounds-row"); + weapon.Add(row); + rounds = Caption(row, "", "combat-rounds"); + reload = Caption(row, "", "combat-small"); + secondary = Caption(weapon, "", "combat-secondary"); + cargo = Caption(vitals, "", "combat-cargo"); + reloadHint = GameLocalization.Text("ui.combat.reload", game.Settings.Bindings.Display("Reload")); + } + + VisualElement Panel(string style) + { + var panel = new VisualElement(); + panel.AddToClassList("combat-panel"); + panel.AddToClassList(style); + field.Add(panel); + return panel; + } + + static Label Caption(VisualElement parent, string text, string style) + { + var label = new Label(text); + label.AddToClassList(style); + parent.Add(label); + return label; + } + + public void Refresh() + { + var pilot = game.Pilot; + field.EnableInClassList("cockpit-hud", pilot.FirstPerson); + bool redraw = Time.unscaledTime >= nextMapUpdate; + if (!redraw && pilot.Rounds == displayedRounds) return; + displayedRounds = pilot.Rounds; + primary.text = pilot.Primary.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; + 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); + if (redraw) + { + map.MarkDirtyRepaint(); + nextMapUpdate = Time.unscaledTime + .075f; + } + } + + void DrawMap(MeshGenerationContext context) + { + var pilot = game.Pilot; + if (!pilot || !game.InField) return; + var painter = context.painter2D; + var rect = map.contentRect; + // Keep north up and use one scale on both axes. Include the pilot past the yard edges. + var position = pilot.transform.position; + var scale = Mathf.Min(rect.width / Mathf.Max(90, Mathf.Abs(position.x) * 2 + 8), + rect.height / Mathf.Max(100, Mathf.Abs(position.z - 8) * 2 + 8)); + Vector2 Project(float x, float z) => rect.center + new Vector2(x, 8 - z) * scale; + void Line(Vector2 a, Vector2 b) + { + painter.BeginPath(); painter.MoveTo(a); painter.LineTo(b); painter.Stroke(); + } + painter.lineWidth = 1; + painter.strokeColor = new Color(.15f, .28f, .30f); + for (int x = -30; x <= 30; x += 10) Line(Project(x, -40), Project(x, 56)); + for (int z = -40; z <= 50; z += 10) Line(Project(-35, z), Project(35, z)); + painter.lineWidth = 2; + painter.strokeColor = new Color(.33f, .49f, .5f); + Line(Project(-35, -38), Project(-35, 58)); + Line(Project(35, -38), Project(35, 58)); + painter.fillColor = painter.strokeColor; + for (int i = 0; i < game.World.Cover.Count; i++) + { + var box = game.World.Cover[i]; + painter.BeginPath(); painter.MoveTo(Project(box.xMin, box.yMin)); + painter.LineTo(Project(box.xMax, box.yMin)); painter.LineTo(Project(box.xMax, box.yMax)); + painter.LineTo(Project(box.xMin, box.yMax)); painter.ClosePath(); painter.Fill(); + } + var target = game.World.NavigationTarget; + if (target) + { + var pos = Project(target.position.x, target.position.z); + painter.strokeColor = new Color(.45f, .84f, 1); + painter.BeginPath(); painter.MoveTo(pos + Vector2.up * 6); + painter.LineTo(pos + Vector2.right * 6); painter.LineTo(pos + Vector2.down * 6); + painter.LineTo(pos + Vector2.left * 6); painter.ClosePath(); painter.Stroke(); + } + var pad = game.World.ExtractionPosition; + painter.strokeColor = game.World.TransportReady ? Color.green : new Color(1, .72f, .36f); + painter.BeginPath(); painter.Arc(Project(pad.x, pad.z), 6, 0, 360); painter.Stroke(); + var origin = Project(position.x, position.z); + var radians = pilot.Heading * Mathf.Deg2Rad; + var forward = new Vector2(Mathf.Sin(radians), -Mathf.Cos(radians)); + var right = new Vector2(-forward.y, forward.x); + painter.fillColor = new Color(.43f, 1, .80f); + painter.BeginPath(); painter.MoveTo(origin + forward * 9); + painter.LineTo(origin - forward * 6 + right * 5); painter.LineTo(origin - forward * 6 - right * 5); + painter.ClosePath(); painter.Fill(); + } + } +} diff --git a/Assets/Game/Scripts/UI/CombatInstruments.cs.meta b/Assets/Game/Scripts/UI/CombatInstruments.cs.meta new file mode 100644 index 0000000..cfe5870 --- /dev/null +++ b/Assets/Game/Scripts/UI/CombatInstruments.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6dc5857341153c740bcee60a2f187f06 \ No newline at end of file diff --git a/Assets/Game/Scripts/UI/GameHud.cs b/Assets/Game/Scripts/UI/GameHud.cs index 7bfe1ad..82438d9 100644 --- a/Assets/Game/Scripts/UI/GameHud.cs +++ b/Assets/Game/Scripts/UI/GameHud.cs @@ -11,10 +11,11 @@ namespace Ironspore VisualElement root; Label mission, prompt, status, notice, telemetry; ProgressBar armor, energy, cargo; - VisualElement cross; + VisualElement cross, controlsHint; + CombatInstruments instruments; enum View { - Hangar, Field, Cargo, Part, Routes, Pause + Hangar, Field, Cargo, Part, Routes, Pause, Settings } View view; int partSlot, partIndex; @@ -24,6 +25,11 @@ namespace Ironspore int lastLanguageActivationFrame = -1; int lastLanguageNavigationFrame = -1; Label hangarNotice; + SettingsScreen settingsScreen; + int menuInputFrame = -1; + public bool SettingsOpen => view == View.Settings; + public bool MenuInputConsumedThisFrame => menuInputFrame == Time.frameCount; + string K(string action) => game.Settings.Bindings.Display(action); static string L(string key, params object[] args) => GameLocalization.Text(key, args); public void Initialize(GameSession session) { @@ -39,6 +45,7 @@ namespace Ironspore root.style.unityFontDefinition = FontDefinition.FromSDFFont(game.localization.uiFont); root.RegisterCallback(HandleLanguageKey, TrickleDown.TrickleDown); GameLocalization.Changed += RefreshLocale; + settingsScreen = new SettingsScreen(game.Settings, () => game.Action("Pause").WasPressedThisFrame()); } void HandleLanguageKey(KeyDownEvent evt) { @@ -106,10 +113,12 @@ namespace Ironspore } void Clear() { + settingsScreen?.Close(); root.Clear(); mission = prompt = status = notice = telemetry = null; armor = energy = cargo = null; - cross = null; + cross = controlsHint = null; + instruments = null; hangarNotice = null; languageControl = languageMenu = languageDismiss = null; languageButton = null; @@ -137,6 +146,9 @@ namespace Ironspore case View.Pause: ShowPause(); break; + case View.Settings: + settingsScreen.RefreshLocale(); + break; } } void AddLanguageControl(VisualElement page) @@ -242,10 +254,11 @@ namespace Ironspore if (!game.StartingPartsUnlocked()) Text(content, L("ui.home.locked"), "warning"); Button(controls, L("ui.home.range"), game.StartTrial); + Button(controls, L("settings.title"), ShowSettings).name = "home-settings"; if (game.Store.Data.run.active) Button(controls, L("ui.home.resume"), game.Resume, "primary"); - Text(content, L("ui.home.moveHint"), "hint"); - Text(content, L("ui.home.fireHint"), "hint"); + Text(content, L("ui.home.moveHint", K("Move"), K("View"), K("Boost"), K("Jump")), "hint"); + Text(content, L("ui.home.fireHint", K("Fire"), K("Aim"), K("Secondary"), K("Guard"), K("Interact")), "hint"); var save = game.Store.Data; Text(content, L("ui.home.inventory"), "section"); Text(content, L("ui.home.stock", save.alloy, save.cells, save.blueprints.Count, save.extractions, save.completed), "budget"); @@ -273,7 +286,7 @@ namespace Ironspore var goal = Element(field, "mission"); Text(goal, L("ui.hud.operation"), "section"); mission = Text(goal, "", "mission-text"); - telemetry = Text(field, "", "telemetry"); + telemetry = Text(goal, "", "combat-extraction"); cross = Element(field, "crosshair"); cross.style.position = Position.Absolute; cross.style.left = Length.Percent(50); @@ -294,16 +307,17 @@ namespace Ironspore prompt = Text(field, "", "interact"); notice = Text(field, "", "notice"); var bottom = Element(field, "bottom"); + instruments = new CombatInstruments(field, bottom, game); armor = new ProgressBar { title = L("ui.hud.armor", 0), lowValue = 0, highValue = 100 }; armor.AddToClassList("armor"); bottom.Add(armor); energy = new ProgressBar { title = L("ui.hud.energy", 0), lowValue = 0, highValue = 100 }; energy.AddToClassList("energy"); bottom.Add(energy); - cargo = new ProgressBar { title = L("ui.hud.podReady"), lowValue = 0, highValue = 3 }; + cargo = new ProgressBar { title = L("ui.hud.podReady", K("Inventory")), lowValue = 0, highValue = 3 }; cargo.AddToClassList("cargo-progress"); bottom.Add(cargo); - Text(bottom, L("ui.hud.hint"), "hint"); + controlsHint = Text(bottom, L("ui.hud.hint", K("View"), K("Boost"), K("Jump"), K("Secondary"), K("Guard"), K("Interact"), K("Inventory"), K("Cargo"), K("Pause")), "hint"); } VisualElement Overlay(string heading, string body) { @@ -316,7 +330,7 @@ namespace Ironspore } public void ShowCargo() { - var panel = Overlay(L("ui.cargo.title"), L(game.Run.podUsed ? "ui.cargo.used" : "ui.cargo.help")); + 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) { @@ -356,12 +370,33 @@ namespace Ironspore var panel = Overlay(L("ui.pause.title"), L("ui.pause.help")); view = View.Pause; Button(panel, L("ui.pause.resume"), game.CloseMenu, "primary"); + Button(panel, L("settings.title"), ShowSettings).name = "pause-settings"; if (game.Trial) Button(panel, L("ui.pause.range"), () => { game.CloseMenu(); game.EndSortie(true); }); Button(panel, L("ui.pause.quit"), () => Application.Quit()); } + public void ShowSettings() + { + bool returnToPause = view == View.Pause; + Clear(); + view = View.Settings; + menuInputFrame = Time.frameCount; + settingsScreen.Open(root, () => + { + if (returnToPause) ShowPause(); + else ShowHangar(); + root.Q