feat: 입고 PDA UI (UI Toolkit) — 보류 작업물 정리

캐릭터-first 전환 전에 만든 PDA/창고 UI 작업물을 히스토리에 보존.
- InboundPda.uxml/.uss/.tss + InboundPanelSettings
- InboundUIController: 단계별 렌더링 + 입력 위임
- InboundSceneSetup: UIDocument/PanelSettings 자동 와이어링
씬에는 비활성 상태로 존재. 추후 입고 로직 재연결 시 재사용 가능.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jklee
2026-06-14 20:08:17 +09:00
parent f8577c5f7d
commit 0a372cb88d
15 changed files with 902 additions and 4 deletions
@@ -0,0 +1,78 @@
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.UIElements;
using SinoWms.Inbound;
using SinoWms.UI;
namespace SinoWms.EditorTools
{
/// <summary>
/// 입고 UI 씬을 한 번에 구성한다(런타임 UI Toolkit 와이어링 자동화).
/// 메뉴 <b>SinoWMS/Setup Inbound Scene</b> 실행 후 Play 하면 PDA 미션이 뜬다.
/// </summary>
public static class InboundSceneSetup
{
private const string UiDir = "Assets/UI";
private const string ThemePath = UiDir + "/InboundRuntimeTheme.tss";
private const string UxmlPath = UiDir + "/InboundPda.uxml";
private const string UssPath = UiDir + "/InboundPda.uss";
private const string PanelSettingsPath = UiDir + "/InboundPanelSettings.asset";
[MenuItem("SinoWMS/Setup Inbound Scene")]
public static void Setup()
{
var panelSettings = EnsurePanelSettings();
if (panelSettings == null) return;
var uxml = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath);
if (uxml == null)
{
Debug.LogError($"[InboundSceneSetup] UXML을 찾을 수 없습니다: {UxmlPath}");
return;
}
var go = GameObject.Find("InboundUI") ?? new GameObject("InboundUI");
var manager = go.GetComponent<InboundManager>() ?? go.AddComponent<InboundManager>();
var doc = go.GetComponent<UIDocument>() ?? go.AddComponent<UIDocument>();
doc.panelSettings = panelSettings;
doc.visualTreeAsset = uxml;
// 컨트롤러는 UIDocument 이후에 추가(RequireComponent 충족) + manager 연결
var controller = go.GetComponent<InboundUIController>() ?? go.AddComponent<InboundUIController>();
var styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
if (styleSheet == null)
Debug.LogWarning($"[InboundSceneSetup] USS를 찾을 수 없습니다: {UssPath}");
var so = new SerializedObject(controller);
so.FindProperty("manager").objectReferenceValue = manager;
so.FindProperty("styleSheet").objectReferenceValue = styleSheet;
so.ApplyModifiedProperties();
Selection.activeGameObject = go;
EditorSceneManager.MarkSceneDirty(go.scene);
Debug.Log("[InboundSceneSetup] 완료. 'InboundUI' 오브젝트 생성 → Play 하면 미션이 시작됩니다.");
}
private static PanelSettings EnsurePanelSettings()
{
var existing = AssetDatabase.LoadAssetAtPath<PanelSettings>(PanelSettingsPath);
if (existing != null) return existing;
var theme = AssetDatabase.LoadAssetAtPath<ThemeStyleSheet>(ThemePath);
if (theme == null)
{
Debug.LogError($"[InboundSceneSetup] 런타임 테마를 찾을 수 없습니다: {ThemePath}");
return null;
}
var ps = ScriptableObject.CreateInstance<PanelSettings>();
ps.themeStyleSheet = theme;
AssetDatabase.CreateAsset(ps, PanelSettingsPath);
AssetDatabase.SaveAssets();
Debug.Log($"[InboundSceneSetup] PanelSettings 생성: {PanelSettingsPath}");
return ps;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4a169582dd1dc4035b39ac7c0635fadb
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 09512757295aa4e849a36067192a5348
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+419
View File
@@ -0,0 +1,419 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
using SinoWms.Core;
using SinoWms.Data;
using SinoWms.Inbound;
namespace SinoWms.UI
{
/// <summary>
/// 입고 미션 PDA + 탑다운 창고 UI (UI Toolkit).
/// InboundManager의 이벤트를 구독해 단계별 화면을 다시 그리고, 입력을 매니저로 위임한다.
/// mockup/inbound_pda.html 의 화면 흐름을 그대로 옮긴 구현.
/// </summary>
[RequireComponent(typeof(UIDocument))]
public class InboundUIController : MonoBehaviour
{
[SerializeField] private InboundManager manager;
[SerializeField] private StyleSheet styleSheet; // UXML <Style> 대신 코드로 확실히 적용
[SerializeField] private string scenarioResource = "Scenarios/inbound_basic";
[SerializeField] private string warehouseResource = "Scenarios/warehouse_basic";
private ScenarioLoader.LoadedScenario _loaded;
private LineProgress _currentLine; // 스캔으로 매칭된, 처리 중인 라인
private int _finalScore = -1;
private VisualElement _grid;
private VisualElement _stepbar;
private VisualElement _content;
private VisualElement _feedback;
private Label _scenarioSub;
private string _fbMsg;
private bool _fbOk;
private void OnEnable()
{
var root = GetComponent<UIDocument>().rootVisualElement;
if (styleSheet != null && !root.styleSheets.Contains(styleSheet))
root.styleSheets.Add(styleSheet);
_grid = root.Q("grid");
_stepbar = root.Q("stepbar");
_content = root.Q("content");
_feedback = root.Q("feedback");
_scenarioSub = root.Q<Label>("scenario-sub");
if (manager == null) manager = GetComponent<InboundManager>();
if (manager == null)
{
Debug.LogError("[InboundUIController] InboundManager를 찾을 수 없습니다.");
return;
}
_loaded = ScenarioLoader.LoadFromResources(scenarioResource, warehouseResource);
if (_scenarioSub != null)
_scenarioSub.text = $"{_loaded.scenario.title} ({_loaded.scenario.scenarioId})";
manager.StepChanged += OnStep;
manager.Feedback += OnFeedback;
manager.Completed += OnCompleted;
manager.StartMission(_loaded.scenario);
}
private void OnDisable()
{
if (manager == null) return;
manager.StepChanged -= OnStep;
manager.Feedback -= OnFeedback;
manager.Completed -= OnCompleted;
}
// ---- 매니저 이벤트 ----
private void OnStep(InboundStep step)
{
// 완료 화면은 점수를 받은 OnCompleted에서 그린다(StepChanged가 먼저 와서 점수가 비어있음).
if (step == InboundStep.Completed) return;
Render(step);
}
private void OnFeedback(string msg)
{
_fbMsg = msg;
_fbOk = !string.IsNullOrEmpty(msg) && !msg.Contains("⚠");
RenderFeedback();
}
private void OnCompleted(int score)
{
_finalScore = score;
Render(InboundStep.Completed);
}
// ---- 렌더링 ----
private void Render(InboundStep step)
{
RenderStepBar(step);
RenderGrid(step);
_content.Clear();
switch (step)
{
case InboundStep.ReviewAsn: RenderReview(); break;
case InboundStep.Scan: RenderScan(); break;
case InboundStep.Inspect: RenderInspect(); break;
case InboundStep.SelectLocation: RenderSelectLocation(); break;
case InboundStep.Completed: RenderResult(); break;
}
RenderFeedback();
}
private void RenderStepBar(InboundStep step)
{
_stepbar.Clear();
int current = (int)step;
for (int i = 0; i < 6; i++)
{
var seg = new VisualElement();
seg.AddToClassList("step-seg");
if (i <= current) seg.AddToClassList("step-seg--on");
_stepbar.Add(seg);
}
}
private void RenderGrid(InboundStep step)
{
_grid.Clear();
bool selectable = step == InboundStep.SelectLocation && _currentLine != null;
// gridY 행 → gridX 열 순으로 배치
var rows = new SortedDictionary<int, List<LocationDefinition>>();
foreach (var loc in _loaded.locations)
{
if (!rows.TryGetValue(loc.gridPosition.y, out var list))
rows[loc.gridPosition.y] = list = new List<LocationDefinition>();
list.Add(loc);
}
foreach (var kv in rows)
{
kv.Value.Sort((a, b) => a.gridPosition.x.CompareTo(b.gridPosition.x));
var rowEl = new VisualElement();
rowEl.AddToClassList("grid-row");
foreach (var loc in kv.Value)
rowEl.Add(BuildLocationCell(loc, selectable));
_grid.Add(rowEl);
}
}
private VisualElement BuildLocationCell(LocationDefinition loc, bool selectable)
{
int used = UsedVolume(loc);
bool filled = used > 0;
var cell = new VisualElement();
cell.AddToClassList("loc");
cell.AddToClassList(ZoneClass(loc.zone));
if (filled) cell.AddToClassList("loc--filled");
var code = new Label(loc.locationCode);
code.AddToClassList("loc-code");
var cap = new Label($"{used}/{loc.capacity}");
cap.AddToClassList("loc-cap");
cell.Add(code);
cell.Add(cap);
if (selectable && !filled)
{
cell.AddToClassList("loc--selectable");
cell.RegisterCallback<ClickEvent>(_ => manager.HandlePutaway(_currentLine, loc));
}
return cell;
}
private void RenderReview()
{
_content.Add(StepTitle("STEP 1 / 입고예정"));
_content.Add(StepHead($"오늘 입고예정 {_loaded.scenario.lines.Count}건"));
foreach (var line in _loaded.scenario.lines)
{
var card = LineCard(line.item, $"예정 {line.expectedQty} · {line.item.barcode}");
_content.Add(card);
}
_content.Add(PrimaryButton("확인 완료 → 스캔 시작", () => manager.ConfirmAsnReviewed()));
}
private void RenderScan()
{
var lp = NextUnscanned();
if (lp == null) return;
var line = lp.line;
_content.Add(StepTitle($"STEP 2 / 스캔 ({ProcessedCount() + 1}/{_loaded.scenario.lines.Count})"));
_content.Add(StepHead("바코드를 스캔하세요"));
_content.Add(LineCard(line.item, $"정답 바코드: {line.item.barcode}"));
var input = new TextField { value = line.item.barcode };
_content.Add(input);
_content.Add(PrimaryButton("📷 스캔", () =>
{
var matched = manager.HandleScan(input.value);
if (matched != null) _currentLine = matched;
}));
_content.Add(GhostButton("잘못된 바코드 스캔 (오류 체험)",
() => manager.HandleScan("0000000000000")));
}
private void RenderInspect()
{
if (_currentLine == null) return;
var line = _currentLine.line;
_content.Add(StepTitle("STEP 3 / 검수"));
_content.Add(StepHead("실제 수량을 세어 입력"));
var card = new VisualElement();
card.AddToClassList("line-card");
var name = new Label(line.item.displayName);
name.AddToClassList("name");
card.Add(name);
card.Add(Meta($"예정 수량: {line.expectedQty}"));
card.Add(Meta($"실제 도착: {line.actualQty}" +
(line.expectedQty != line.actualQty ? " ⚠ 예정과 다름" : "")));
_content.Add(card);
var qty = new IntegerField { value = line.expectedQty };
_content.Add(qty);
_content.Add(PrimaryButton("검수 확정", () => manager.HandleInspect(_currentLine, qty.value)));
}
private void RenderSelectLocation()
{
if (_currentLine == null) return;
var line = _currentLine.line;
int need = _currentLine.countedQty * line.item.unitVolume;
_content.Add(StepTitle("STEP 4 / 로케이션 선택"));
_content.Add(StepHead("적치할 위치를 클릭 →"));
var card = LineCard(line.item, $"필요 용량: {need} · 요구 존: {ZoneLabel(line.item.requiredZone)}");
var hint = Meta($"왼쪽 격자에서 {ZoneLabel(line.item.requiredZone)} 칸을 선택하세요");
hint.style.color = new StyleColor(new Color32(43, 111, 255, 255));
card.Add(hint);
_content.Add(card);
}
private void RenderResult()
{
_content.Add(StepTitle("완료"));
_content.Add(StepHead("교육 결과"));
var score = new Label($"{_finalScore}점");
score.AddToClassList("result-score");
_content.Add(score);
var errors = manager.GetErrors();
var summary = new VisualElement();
summary.AddToClassList("line-card");
summary.Add(Meta($"처리 {_loaded.scenario.lines.Count}건 · 오류 {errors.Count}건"));
_content.Add(summary);
if (errors.Count > 0)
{
var errBox = new VisualElement();
errBox.AddToClassList("line-card");
foreach (var e in errors)
{
var item = new Label($"⚠ {e.message}");
item.AddToClassList("err-item");
errBox.Add(item);
}
_content.Add(errBox);
}
else
{
var ok = new Label("완벽합니다! 오류 없음 👏");
ok.AddToClassList("feedback");
ok.AddToClassList("fb-ok");
_content.Add(ok);
}
_content.Add(PrimaryButton("다시 하기", Restart));
}
private void RenderFeedback()
{
if (_feedback == null) return;
_feedback.Clear();
if (string.IsNullOrEmpty(_fbMsg)) return;
var fb = new Label(_fbMsg);
fb.AddToClassList("feedback");
fb.AddToClassList(_fbOk ? "fb-ok" : "fb-warn");
_feedback.Add(fb);
}
// ---- 헬퍼 ----
private void Restart()
{
_currentLine = null;
_finalScore = -1;
_fbMsg = null;
manager.StartMission(_loaded.scenario);
}
private LineProgress NextUnscanned()
{
foreach (var lp in manager.Progress)
if (!lp.scanned) return lp;
return null;
}
private int ProcessedCount()
{
int n = 0;
foreach (var lp in manager.Progress)
if (lp.putaway) n++;
return n;
}
private int UsedVolume(LocationDefinition loc)
{
int used = 0;
foreach (var lp in manager.Progress)
if (lp.placedLocation == loc)
used += lp.countedQty * lp.line.item.unitVolume;
return used;
}
private static Label StepTitle(string t)
{
var l = new Label(t);
l.AddToClassList("step-title");
return l;
}
private static Label StepHead(string t)
{
var l = new Label(t);
l.AddToClassList("step-head");
return l;
}
private static Label Meta(string t)
{
var l = new Label(t);
l.AddToClassList("meta");
return l;
}
private static VisualElement LineCard(ItemDefinition item, string metaText)
{
var card = new VisualElement();
card.AddToClassList("line-card");
var nameRow = new VisualElement();
nameRow.AddToClassList("row");
var name = new Label(item.displayName);
name.AddToClassList("name");
nameRow.Add(name);
nameRow.Add(ZoneBadge(item.requiredZone));
card.Add(nameRow);
card.Add(Meta(metaText));
return card;
}
private static Label ZoneBadge(StorageZone zone)
{
var b = new Label(ZoneLabel(zone));
b.AddToClassList("badge");
switch (zone)
{
case StorageZone.Chilled: b.AddToClassList("b-chilled"); break;
case StorageZone.Frozen: b.AddToClassList("b-frozen"); break;
default: b.AddToClassList("b-ambient"); break;
}
return b;
}
private static Button PrimaryButton(string text, System.Action onClick)
{
var btn = new Button(onClick) { text = text };
return btn;
}
private static Button GhostButton(string text, System.Action onClick)
{
var btn = new Button(onClick) { text = text };
btn.AddToClassList("ghost");
return btn;
}
private static string ZoneClass(StorageZone zone)
{
switch (zone)
{
case StorageZone.Chilled: return "chilled";
case StorageZone.Frozen: return "frozen";
default: return "ambient";
}
}
private static string ZoneLabel(StorageZone zone)
{
switch (zone)
{
case StorageZone.Chilled: return "냉장";
case StorageZone.Frozen: return "냉동";
case StorageZone.Hazardous: return "위험물";
default: return "상온";
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dcb5c4bbb0d9448658ddc5b9fbe59d64