Compare commits
10 Commits
08ea11ea37
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 298e35c50b | |||
| f230b438ed | |||
| aa302b1d67 | |||
| 0a372cb88d | |||
| f8577c5f7d | |||
| f5ae70b0aa | |||
| 065355f63a | |||
| de5a40678e | |||
| 1051958641 | |||
| 58789473ff |
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 1a100f0ac0d344e49ae2c8f72666cbfc
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c5cc94c1a8e694a5ab13534866990886
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d9b22d7d9796c45f3a3162d09f9a832b
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"warehouseId": "WH-BASIC",
|
||||||
|
"title": "기본 교육 창고 레이아웃",
|
||||||
|
"locations": [
|
||||||
|
{ "locationCode": "A-01-01", "zone": "Ambient", "capacity": 50, "gridX": 0, "gridY": 0 },
|
||||||
|
{ "locationCode": "A-02-01", "zone": "Ambient", "capacity": 50, "gridX": 1, "gridY": 0 },
|
||||||
|
{ "locationCode": "A-03-02", "zone": "Ambient", "capacity": 50, "gridX": 2, "gridY": 0 },
|
||||||
|
{ "locationCode": "A-99-01", "zone": "Ambient", "capacity": 5, "gridX": 3, "gridY": 0 },
|
||||||
|
{ "locationCode": "C-01-01", "zone": "Chilled", "capacity": 40, "gridX": 0, "gridY": 2 },
|
||||||
|
{ "locationCode": "C-02-03", "zone": "Chilled", "capacity": 40, "gridX": 1, "gridY": 2 },
|
||||||
|
{ "locationCode": "F-01-01", "zone": "Frozen", "capacity": 30, "gridX": 0, "gridY": 4 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0098b9785d1154887bbf01a69d6c560d
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: dcc84db7afabd49529a9f8f3a297f3b7
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: acd0ae7706a33463bb61bd0d2fa81b75
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 1a7a1548c6ce04a9081c55e86ea77cd3
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
using SinoWms.Data;
|
||||||
|
|
||||||
|
namespace SinoWms.Core
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// JSON 시나리오/창고 레이아웃을 읽어 런타임 ScriptableObject 인스턴스로 변환한다.
|
||||||
|
/// 교육 콘텐츠를 코드와 분리하기 위한 진입점. UI/매니저는 이 결과를 받아 미션을 시작한다.
|
||||||
|
/// </summary>
|
||||||
|
public static class ScenarioLoader
|
||||||
|
{
|
||||||
|
/// <summary>로더 결과 묶음: 미션 시나리오 + 해당 창고의 로케이션 마스터.</summary>
|
||||||
|
public class LoadedScenario
|
||||||
|
{
|
||||||
|
public InboundScenario scenario;
|
||||||
|
public List<LocationDefinition> locations = new List<LocationDefinition>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resources 폴더에서 시나리오/창고 JSON(TextAsset)을 읽어 로드한다.
|
||||||
|
/// 경로는 확장자 없이 Resources 기준 상대경로 (예: "Scenarios/inbound_basic").
|
||||||
|
/// </summary>
|
||||||
|
public static LoadedScenario LoadFromResources(string scenarioPath, string warehousePath)
|
||||||
|
{
|
||||||
|
var scenarioAsset = Resources.Load<TextAsset>(scenarioPath);
|
||||||
|
if (scenarioAsset == null)
|
||||||
|
throw new InvalidOperationException($"시나리오 JSON을 찾을 수 없습니다: Resources/{scenarioPath}");
|
||||||
|
|
||||||
|
var warehouseAsset = Resources.Load<TextAsset>(warehousePath);
|
||||||
|
if (warehouseAsset == null)
|
||||||
|
throw new InvalidOperationException($"창고 JSON을 찾을 수 없습니다: Resources/{warehousePath}");
|
||||||
|
|
||||||
|
return Load(scenarioAsset.text, warehouseAsset.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>JSON 문자열로부터 직접 로드한다(테스트/외부 소스용).</summary>
|
||||||
|
public static LoadedScenario Load(string scenarioJson, string warehouseJson)
|
||||||
|
{
|
||||||
|
var warehouseDto = JsonUtility.FromJson<WarehouseDto>(warehouseJson);
|
||||||
|
if (warehouseDto?.locations == null)
|
||||||
|
throw new InvalidOperationException("창고 JSON 파싱 실패: locations가 없습니다.");
|
||||||
|
|
||||||
|
var result = new LoadedScenario();
|
||||||
|
var byCode = new Dictionary<string, LocationDefinition>();
|
||||||
|
foreach (var dto in warehouseDto.locations)
|
||||||
|
{
|
||||||
|
var loc = ScriptableObject.CreateInstance<LocationDefinition>();
|
||||||
|
loc.name = dto.locationCode;
|
||||||
|
loc.locationCode = dto.locationCode;
|
||||||
|
loc.zone = ParseZone(dto.zone, dto.locationCode);
|
||||||
|
loc.capacity = dto.capacity;
|
||||||
|
loc.gridPosition = new Vector2Int(dto.gridX, dto.gridY);
|
||||||
|
|
||||||
|
result.locations.Add(loc);
|
||||||
|
byCode[loc.locationCode] = loc;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scenarioDto = JsonUtility.FromJson<ScenarioDto>(scenarioJson);
|
||||||
|
if (scenarioDto?.lines == null)
|
||||||
|
throw new InvalidOperationException("시나리오 JSON 파싱 실패: lines가 없습니다.");
|
||||||
|
|
||||||
|
var scenario = ScriptableObject.CreateInstance<InboundScenario>();
|
||||||
|
scenario.name = scenarioDto.scenarioId;
|
||||||
|
scenario.scenarioId = scenarioDto.scenarioId;
|
||||||
|
scenario.title = scenarioDto.title;
|
||||||
|
scenario.timeLimitSeconds = scenarioDto.timeLimitSeconds;
|
||||||
|
|
||||||
|
foreach (var lineDto in scenarioDto.lines)
|
||||||
|
{
|
||||||
|
var item = ScriptableObject.CreateInstance<ItemDefinition>();
|
||||||
|
item.name = lineDto.sku;
|
||||||
|
item.sku = lineDto.sku;
|
||||||
|
item.displayName = lineDto.displayName;
|
||||||
|
item.barcode = lineDto.barcode;
|
||||||
|
item.requiredZone = ParseZone(lineDto.requiredZone, lineDto.sku);
|
||||||
|
item.unitVolume = lineDto.unitVolume;
|
||||||
|
|
||||||
|
LocationDefinition suggested = null;
|
||||||
|
if (!string.IsNullOrEmpty(lineDto.suggestedLocation))
|
||||||
|
{
|
||||||
|
if (!byCode.TryGetValue(lineDto.suggestedLocation, out suggested))
|
||||||
|
Debug.LogWarning($"[ScenarioLoader] {lineDto.sku}의 정답 로케이션 " +
|
||||||
|
$"'{lineDto.suggestedLocation}'이(가) 창고에 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario.lines.Add(new InboundLine
|
||||||
|
{
|
||||||
|
item = item,
|
||||||
|
expectedQty = lineDto.expectedQty,
|
||||||
|
actualQty = lineDto.actualQty,
|
||||||
|
suggestedLocation = suggested
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
result.scenario = scenario;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StorageZone ParseZone(string raw, string ownerForLog)
|
||||||
|
{
|
||||||
|
if (Enum.TryParse(raw, ignoreCase: true, out StorageZone zone))
|
||||||
|
return zone;
|
||||||
|
|
||||||
|
Debug.LogWarning($"[ScenarioLoader] 알 수 없는 존 '{raw}' ({ownerForLog}) → Ambient로 대체.");
|
||||||
|
return StorageZone.Ambient;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- JSON 매핑용 DTO (JsonUtility는 public 필드 + [Serializable] 필요) ----
|
||||||
|
|
||||||
|
[Serializable]
|
||||||
|
private class ScenarioDto
|
||||||
|
{
|
||||||
|
public string scenarioId;
|
||||||
|
public string title;
|
||||||
|
public float timeLimitSeconds;
|
||||||
|
public LineDto[] lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Serializable]
|
||||||
|
private class LineDto
|
||||||
|
{
|
||||||
|
public string sku;
|
||||||
|
public string displayName;
|
||||||
|
public string barcode;
|
||||||
|
public string requiredZone;
|
||||||
|
public int unitVolume;
|
||||||
|
public int expectedQty;
|
||||||
|
public int actualQty;
|
||||||
|
public string suggestedLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Serializable]
|
||||||
|
private class WarehouseDto
|
||||||
|
{
|
||||||
|
public string warehouseId;
|
||||||
|
public string title;
|
||||||
|
public LocationDto[] locations;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Serializable]
|
||||||
|
private class LocationDto
|
||||||
|
{
|
||||||
|
public string locationCode;
|
||||||
|
public string zone;
|
||||||
|
public int capacity;
|
||||||
|
public int gridX;
|
||||||
|
public int gridY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: fe252208b8d334ce181dd57a84601eb1
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 735eff26b1fd44b1f8c7833d162f02f9
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 23defe8227a9a4b75b3a5370762cbf6b
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a5a424b0f17934e8c8e71320de630603
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: be47f82ae9bb545f4832521317042441
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 562ff628324604008823b883ae8e9e18
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d60fd71c0f2ac4481aa3dbbab31828e9
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
using SinoWms.Core;
|
||||||
|
using SinoWms.Data;
|
||||||
|
using SinoWms.Inbound;
|
||||||
|
|
||||||
|
namespace SinoWms.EditorTools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// UI 없이 입고 로직(로더 + 서비스)을 검증하는 스모크 테스트.
|
||||||
|
/// 메뉴 <b>SinoWMS/Run Inbound Smoke Test</b>로 실행하면 콘솔에 PASS/FAIL 리포트가 찍힌다.
|
||||||
|
/// 정식 EditMode 테스트(asmdef 분리)로 대체되기 전까지의 임시 검증 수단.
|
||||||
|
/// </summary>
|
||||||
|
public static class InboundSmokeTest
|
||||||
|
{
|
||||||
|
private static int _pass;
|
||||||
|
private static int _fail;
|
||||||
|
|
||||||
|
[MenuItem("SinoWMS/Run Inbound Smoke Test")]
|
||||||
|
public static void Run()
|
||||||
|
{
|
||||||
|
_pass = 0;
|
||||||
|
_fail = 0;
|
||||||
|
Debug.Log("===== 입고 스모크 테스트 시작 =====");
|
||||||
|
|
||||||
|
var loaded = ScenarioLoader.LoadFromResources(
|
||||||
|
"Scenarios/inbound_basic", "Scenarios/warehouse_basic");
|
||||||
|
|
||||||
|
TestLoader(loaded);
|
||||||
|
TestHappyPath(loaded);
|
||||||
|
TestUnknownBarcode(loaded);
|
||||||
|
TestPutawayBeforeInspect(loaded);
|
||||||
|
TestZoneMismatch(loaded);
|
||||||
|
TestOverCapacity(loaded);
|
||||||
|
|
||||||
|
var summary = $"===== 결과: {_pass} PASS / {_fail} FAIL =====";
|
||||||
|
if (_fail == 0) Debug.Log(summary);
|
||||||
|
else Debug.LogError(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 개별 테스트 ----
|
||||||
|
|
||||||
|
private static void TestLoader(ScenarioLoader.LoadedScenario l)
|
||||||
|
{
|
||||||
|
Check("로더: 시나리오 ID", l.scenario.scenarioId == "INB-001");
|
||||||
|
Check("로더: 라인 3건", l.scenario.lines.Count == 3);
|
||||||
|
Check("로더: 로케이션 7건", l.locations.Count == 7);
|
||||||
|
Check("로더: 우유 존=Chilled",
|
||||||
|
Line(l, "SKU-2002").item.requiredZone == StorageZone.Chilled);
|
||||||
|
Check("로더: 정답 로케이션 해석(생수→A-01-01)",
|
||||||
|
Line(l, "SKU-1001").suggestedLocation?.locationCode == "A-01-01");
|
||||||
|
Check("로더: 수량 차이 함정(우유 예정8/실제6)",
|
||||||
|
Line(l, "SKU-2002").actualQty == 6 && Line(l, "SKU-2002").expectedQty == 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TestHappyPath(ScenarioLoader.LoadedScenario l)
|
||||||
|
{
|
||||||
|
var progress = NewProgress(l);
|
||||||
|
var scan = new ScanService(progress);
|
||||||
|
var putaway = new PutawayService();
|
||||||
|
|
||||||
|
var lp = progress.First(p => p.line.item.sku == "SKU-1001");
|
||||||
|
var r1 = scan.Scan(lp.line.item.barcode, out _);
|
||||||
|
Check("해피: 정상 스캔", r1 == ScanResult.Ok && lp.scanned);
|
||||||
|
|
||||||
|
lp.countedQty = lp.line.actualQty;
|
||||||
|
lp.inspected = true;
|
||||||
|
|
||||||
|
var r2 = putaway.Commit(lp, Loc(l, "A-01-01"));
|
||||||
|
Check("해피: 정상 적치", r2 == PutawayResult.Ok && lp.putaway);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TestUnknownBarcode(ScenarioLoader.LoadedScenario l)
|
||||||
|
{
|
||||||
|
var scan = new ScanService(NewProgress(l));
|
||||||
|
var r = scan.Scan("0000000000000", out var matched);
|
||||||
|
Check("함정: 미등록 바코드 → UnknownBarcode",
|
||||||
|
r == ScanResult.UnknownBarcode && matched == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TestPutawayBeforeInspect(ScenarioLoader.LoadedScenario l)
|
||||||
|
{
|
||||||
|
var progress = NewProgress(l);
|
||||||
|
var putaway = new PutawayService();
|
||||||
|
var lp = progress.First(p => p.line.item.sku == "SKU-1001");
|
||||||
|
lp.scanned = true; // 검수는 생략
|
||||||
|
var r = putaway.Validate(lp, Loc(l, "A-01-01"));
|
||||||
|
Check("함정: 검수 전 적치 → NotInspected", r == PutawayResult.NotInspected);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TestZoneMismatch(ScenarioLoader.LoadedScenario l)
|
||||||
|
{
|
||||||
|
var progress = NewProgress(l);
|
||||||
|
var putaway = new PutawayService();
|
||||||
|
var lp = progress.First(p => p.line.item.sku == "SKU-1001"); // Ambient 품목
|
||||||
|
lp.countedQty = lp.line.actualQty;
|
||||||
|
lp.inspected = true;
|
||||||
|
var r = putaway.Validate(lp, Loc(l, "C-02-03")); // Chilled 존
|
||||||
|
Check("함정: 존 위반 → ZoneMismatch", r == PutawayResult.ZoneMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TestOverCapacity(ScenarioLoader.LoadedScenario l)
|
||||||
|
{
|
||||||
|
var progress = NewProgress(l);
|
||||||
|
var putaway = new PutawayService();
|
||||||
|
var lp = progress.First(p => p.line.item.sku == "SKU-1001"); // 10개 × 부피2 = 20
|
||||||
|
lp.countedQty = lp.line.actualQty;
|
||||||
|
lp.inspected = true;
|
||||||
|
var r = putaway.Validate(lp, Loc(l, "A-99-01")); // 용량 5
|
||||||
|
Check("함정: 용량 초과 → OverCapacity", r == PutawayResult.OverCapacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 헬퍼 ----
|
||||||
|
|
||||||
|
private static List<LineProgress> NewProgress(ScenarioLoader.LoadedScenario l)
|
||||||
|
=> l.scenario.lines.Select(line => new LineProgress(line)).ToList();
|
||||||
|
|
||||||
|
private static InboundLine Line(ScenarioLoader.LoadedScenario l, string sku)
|
||||||
|
=> l.scenario.lines.First(line => line.item.sku == sku);
|
||||||
|
|
||||||
|
private static LocationDefinition Loc(ScenarioLoader.LoadedScenario l, string code)
|
||||||
|
=> l.locations.First(loc => loc.locationCode == code);
|
||||||
|
|
||||||
|
private static void Check(string label, bool ok)
|
||||||
|
{
|
||||||
|
if (ok) { _pass++; Debug.Log($" ✅ {label}"); }
|
||||||
|
else { _fail++; Debug.LogError($" ❌ {label}"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 75a0eb77b8a87495cb9a80a685e0e1a8
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using UnityEditor;
|
||||||
|
using UnityEditor.SceneManagement;
|
||||||
|
using UnityEngine;
|
||||||
|
using SinoWms.Gameplay;
|
||||||
|
|
||||||
|
namespace SinoWms.EditorTools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 미션 판정 매니저를 씬에 추가한다. 메뉴 <b>SinoWMS/Setup Mission</b>.
|
||||||
|
/// Play 후 모든 슬롯을 정답으로 채우면 완료 + 점수 표시.
|
||||||
|
/// </summary>
|
||||||
|
public static class MissionSetup
|
||||||
|
{
|
||||||
|
[MenuItem("SinoWMS/Setup Mission")]
|
||||||
|
public static void Setup()
|
||||||
|
{
|
||||||
|
var go = GameObject.Find("Mission") ?? new GameObject("Mission");
|
||||||
|
if (go.GetComponent<MissionManager>() == null)
|
||||||
|
go.AddComponent<MissionManager>();
|
||||||
|
|
||||||
|
EditorSceneManager.MarkSceneDirty(go.scene);
|
||||||
|
Selection.activeGameObject = go;
|
||||||
|
Debug.Log("[MissionSetup] 완료. Play 후 모든 슬롯을 정답으로 채우면 미션 완료.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9a8d41437743f4655996e49253cc72ad
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using UnityEditor;
|
||||||
|
using UnityEditor.SceneManagement;
|
||||||
|
using UnityEngine;
|
||||||
|
using SinoWms.Gameplay;
|
||||||
|
using SinoWms.Player;
|
||||||
|
|
||||||
|
namespace SinoWms.EditorTools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 집기/놓기 데모 구성: 플레이어에 PickupController 추가 + 입고 박스 스폰.
|
||||||
|
/// 메뉴 <b>SinoWMS/Setup Pickup Demo</b>. Play 후 박스에 다가가 E키로 집고 놓는다.
|
||||||
|
/// </summary>
|
||||||
|
public static class PickupDemoSetup
|
||||||
|
{
|
||||||
|
[MenuItem("SinoWMS/Setup Pickup Demo")]
|
||||||
|
public static void Setup()
|
||||||
|
{
|
||||||
|
var player = GameObject.Find("Player");
|
||||||
|
if (player == null)
|
||||||
|
{
|
||||||
|
Debug.LogError("[PickupDemoSetup] Player가 없습니다. 먼저 'Setup Playground Scene'을 실행하세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (player.GetComponent<PickupController>() == null)
|
||||||
|
player.AddComponent<PickupController>();
|
||||||
|
|
||||||
|
var container = GameObject.Find("InboundBoxes") ?? new GameObject("InboundBoxes");
|
||||||
|
|
||||||
|
// 입고 대기 박스 4개 (남쪽 통로 근처에 배치)
|
||||||
|
var colors = new[]
|
||||||
|
{
|
||||||
|
new Color(0.75f, 0.55f, 0.35f),
|
||||||
|
new Color(0.70f, 0.50f, 0.30f),
|
||||||
|
new Color(0.80f, 0.60f, 0.40f),
|
||||||
|
new Color(0.65f, 0.45f, 0.28f),
|
||||||
|
};
|
||||||
|
string[] names = { "박스 A (생수)", "박스 B (우유)", "박스 C (라면)", "박스 D (잡화)" };
|
||||||
|
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
string objName = $"Box_{i + 1}";
|
||||||
|
var existing = container.transform.Find(objName);
|
||||||
|
|
||||||
|
GameObject box;
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
box = existing.gameObject;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
box = GameObject.CreatePrimitive(PrimitiveType.Cube);
|
||||||
|
box.name = objName;
|
||||||
|
box.transform.SetParent(container.transform, false);
|
||||||
|
box.transform.localScale = Vector3.one;
|
||||||
|
box.GetComponent<Renderer>().sharedMaterial = MakeMaterial(colors[i]);
|
||||||
|
box.AddComponent<Rigidbody>().mass = 1f;
|
||||||
|
box.AddComponent<Carryable>().displayName = names[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 위치는 항상 갱신(동선 단축 — 플레이어 스폰 바로 남쪽)
|
||||||
|
box.transform.localPosition = new Vector3(-3f + i * 2f, 0.5f, -1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorSceneManager.MarkSceneDirty(player.scene);
|
||||||
|
Selection.activeGameObject = player;
|
||||||
|
Debug.Log("[PickupDemoSetup] 완료. Play 후 박스에 다가가 E키로 집고 놓아 보세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Material MakeMaterial(Color color)
|
||||||
|
{
|
||||||
|
var shader = Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard");
|
||||||
|
return new Material(shader) { color = color };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 10eba722501bf4dcaa1d33cc90d763b1
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using UnityEditor;
|
||||||
|
using UnityEditor.SceneManagement;
|
||||||
|
using UnityEngine;
|
||||||
|
using SinoWms.Player;
|
||||||
|
|
||||||
|
namespace SinoWms.EditorTools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 캐릭터 이동 확인용 최소 플레이그라운드를 구성한다.
|
||||||
|
/// 메뉴 <b>SinoWMS/Setup Playground Scene</b> 실행 후 Play → WASD/방향키로 이동.
|
||||||
|
/// </summary>
|
||||||
|
public static class PlaygroundSetup
|
||||||
|
{
|
||||||
|
[MenuItem("SinoWMS/Setup Playground Scene")]
|
||||||
|
public static void Setup()
|
||||||
|
{
|
||||||
|
// 바닥
|
||||||
|
var ground = GameObject.Find("Ground");
|
||||||
|
if (ground == null)
|
||||||
|
{
|
||||||
|
ground = GameObject.CreatePrimitive(PrimitiveType.Plane);
|
||||||
|
ground.name = "Ground";
|
||||||
|
ground.transform.localScale = new Vector3(5f, 1f, 5f); // 50x50
|
||||||
|
ground.GetComponent<Renderer>().sharedMaterial = MakeMaterial(new Color(0.5f, 0.55f, 0.6f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 플레이어
|
||||||
|
var player = GameObject.Find("Player");
|
||||||
|
if (player == null)
|
||||||
|
{
|
||||||
|
player = GameObject.CreatePrimitive(PrimitiveType.Capsule);
|
||||||
|
player.name = "Player";
|
||||||
|
player.transform.position = new Vector3(0f, 1.1f, 0f);
|
||||||
|
|
||||||
|
// CharacterController와 기본 CapsuleCollider 충돌 방지
|
||||||
|
var capCol = player.GetComponent<CapsuleCollider>();
|
||||||
|
if (capCol != null) Object.DestroyImmediate(capCol);
|
||||||
|
|
||||||
|
var cc = player.AddComponent<CharacterController>();
|
||||||
|
cc.height = 2f;
|
||||||
|
cc.radius = 0.5f;
|
||||||
|
cc.center = Vector3.zero;
|
||||||
|
|
||||||
|
player.AddComponent<PlayerController>();
|
||||||
|
player.GetComponent<Renderer>().sharedMaterial = MakeMaterial(new Color(0.2f, 0.5f, 1f));
|
||||||
|
|
||||||
|
// 정면 표시용 작은 큐브(어느 쪽을 보는지)
|
||||||
|
var nose = GameObject.CreatePrimitive(PrimitiveType.Cube);
|
||||||
|
nose.name = "Nose";
|
||||||
|
Object.DestroyImmediate(nose.GetComponent<BoxCollider>());
|
||||||
|
nose.transform.SetParent(player.transform, false);
|
||||||
|
nose.transform.localScale = new Vector3(0.25f, 0.25f, 0.4f);
|
||||||
|
nose.transform.localPosition = new Vector3(0f, 0.3f, 0.5f);
|
||||||
|
nose.GetComponent<Renderer>().sharedMaterial = MakeMaterial(new Color(1f, 0.85f, 0.3f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카메라 (탑다운 추적)
|
||||||
|
var cam = Camera.main;
|
||||||
|
if (cam == null)
|
||||||
|
{
|
||||||
|
var camGo = new GameObject("Main Camera") { tag = "MainCamera" };
|
||||||
|
cam = camGo.AddComponent<Camera>();
|
||||||
|
}
|
||||||
|
var follow = cam.GetComponent<CameraFollow>() ?? cam.gameObject.AddComponent<CameraFollow>();
|
||||||
|
follow.SetTarget(player.transform);
|
||||||
|
cam.transform.position = player.transform.position + new Vector3(0f, 12f, -8f);
|
||||||
|
cam.transform.LookAt(player.transform.position);
|
||||||
|
|
||||||
|
Selection.activeGameObject = player;
|
||||||
|
EditorSceneManager.MarkSceneDirty(player.scene);
|
||||||
|
Debug.Log("[PlaygroundSetup] 완료. Play 후 WASD/방향키로 캐릭터를 움직여 보세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>URP 환경에서 분홍색으로 안 보이도록 URP/Lit 머티리얼 생성.</summary>
|
||||||
|
private static Material MakeMaterial(Color color)
|
||||||
|
{
|
||||||
|
var shader = Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard");
|
||||||
|
return new Material(shader) { color = color };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6a6e35879fd6146759034c5dab7c4afd
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using UnityEditor;
|
||||||
|
using UnityEditor.SceneManagement;
|
||||||
|
using UnityEngine;
|
||||||
|
using SinoWms.Gameplay;
|
||||||
|
|
||||||
|
namespace SinoWms.EditorTools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 적치 슬롯을 창고에 배치한다. 각 슬롯은 바닥 패드 + 트리거 콜라이더 + 기대 품목을 가진다.
|
||||||
|
/// 메뉴 <b>SinoWMS/Setup Slots</b>. Play 후 박스를 들고 슬롯에서 E키로 적치(정답=초록/오답=빨강).
|
||||||
|
/// </summary>
|
||||||
|
public static class SlotSetup
|
||||||
|
{
|
||||||
|
// 슬롯 위치와 기대 품목 (PickupDemoSetup의 박스 이름과 일치시켜 정답 구성)
|
||||||
|
private static readonly (Vector3 pos, string expected)[] Slots =
|
||||||
|
{
|
||||||
|
(new Vector3(-3f, 0.03f, 1f), "박스 A (생수)"),
|
||||||
|
(new Vector3(-1f, 0.03f, 1f), "박스 B (우유)"),
|
||||||
|
(new Vector3(1f, 0.03f, 1f), "박스 C (라면)"),
|
||||||
|
(new Vector3(3f, 0.03f, 1f), "박스 D (잡화)"),
|
||||||
|
};
|
||||||
|
|
||||||
|
[MenuItem("SinoWMS/Setup Slots")]
|
||||||
|
public static void Setup()
|
||||||
|
{
|
||||||
|
var container = GameObject.Find("PutawaySlots") ?? new GameObject("PutawaySlots");
|
||||||
|
|
||||||
|
for (int i = 0; i < Slots.Length; i++)
|
||||||
|
{
|
||||||
|
string slotName = $"Slot_{i + 1}";
|
||||||
|
var existing = container.transform.Find(slotName);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
existing.localPosition = Slots[i].pos; // 위치만 갱신
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var slotGo = new GameObject(slotName);
|
||||||
|
slotGo.transform.SetParent(container.transform, false);
|
||||||
|
slotGo.transform.localPosition = Slots[i].pos;
|
||||||
|
|
||||||
|
// 바닥 패드 (상태 색 표시)
|
||||||
|
var pad = GameObject.CreatePrimitive(PrimitiveType.Cube);
|
||||||
|
pad.name = "Pad";
|
||||||
|
Object.DestroyImmediate(pad.GetComponent<BoxCollider>());
|
||||||
|
pad.transform.SetParent(slotGo.transform, false);
|
||||||
|
pad.transform.localScale = new Vector3(1.2f, 0.05f, 1.2f);
|
||||||
|
pad.GetComponent<Renderer>().sharedMaterial =
|
||||||
|
new Material(Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard"))
|
||||||
|
{ color = new Color(1f, 0.82f, 0.3f) };
|
||||||
|
|
||||||
|
// 적치 감지용 트리거
|
||||||
|
var trigger = slotGo.AddComponent<BoxCollider>();
|
||||||
|
trigger.isTrigger = true;
|
||||||
|
trigger.size = new Vector3(1.4f, 1.5f, 1.4f);
|
||||||
|
trigger.center = new Vector3(0f, 0.75f, 0f);
|
||||||
|
|
||||||
|
var slot = slotGo.AddComponent<PutawaySlot>();
|
||||||
|
var so = new SerializedObject(slot);
|
||||||
|
so.FindProperty("pad").objectReferenceValue = pad.GetComponent<Renderer>();
|
||||||
|
so.FindProperty("expectedLabel").stringValue = Slots[i].expected;
|
||||||
|
so.ApplyModifiedProperties();
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorSceneManager.MarkSceneDirty(container.scene);
|
||||||
|
Selection.activeGameObject = container;
|
||||||
|
Debug.Log("[SlotSetup] 완료. 박스를 들고 슬롯 위에서 E키로 적치하세요(정답=초록/오답=빨강).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: bab24c45943e84dc9bf7ace8f7d0d618
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using UnityEditor;
|
||||||
|
using UnityEditor.SceneManagement;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace SinoWms.EditorTools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 캐릭터가 돌아다닐 창고 환경(외벽 + 선반 랙)을 구성한다.
|
||||||
|
/// 메뉴 <b>SinoWMS/Setup Warehouse</b>. 바닥(Ground)은 PlaygroundSetup에서 생성된 것을 사용한다.
|
||||||
|
/// 모든 오브젝트에 콜라이더가 있어 캐릭터가 통과하지 못한다.
|
||||||
|
/// </summary>
|
||||||
|
public static class WarehouseSetup
|
||||||
|
{
|
||||||
|
private const float Half = 25f; // 바닥 50x50 기준 절반
|
||||||
|
private const float WallH = 3f;
|
||||||
|
private const float WallT = 0.5f;
|
||||||
|
|
||||||
|
[MenuItem("SinoWMS/Setup Warehouse")]
|
||||||
|
public static void Setup()
|
||||||
|
{
|
||||||
|
var root = GameObject.Find("Warehouse") ?? new GameObject("Warehouse");
|
||||||
|
|
||||||
|
// ---- 외벽 4면 ----
|
||||||
|
var walls = ChildContainer(root, "Walls");
|
||||||
|
var wallMat = MakeMaterial(new Color(0.78f, 0.78f, 0.82f));
|
||||||
|
MakeBox(walls, "Wall_N", new Vector3(0, WallH / 2, Half), new Vector3(2 * Half, WallH, WallT), wallMat);
|
||||||
|
MakeBox(walls, "Wall_S", new Vector3(0, WallH / 2, -Half), new Vector3(2 * Half, WallH, WallT), wallMat);
|
||||||
|
MakeBox(walls, "Wall_E", new Vector3(Half, WallH / 2, 0), new Vector3(WallT, WallH, 2 * Half), wallMat);
|
||||||
|
MakeBox(walls, "Wall_W", new Vector3(-Half, WallH / 2, 0), new Vector3(WallT, WallH, 2 * Half), wallMat);
|
||||||
|
|
||||||
|
// ---- 선반 랙 (Z축으로 길게, 중앙 통로 비움) ----
|
||||||
|
var racks = ChildContainer(root, "Racks");
|
||||||
|
var rackMat = MakeMaterial(new Color(0.85f, 0.45f, 0.15f)); // 주황(창고 랙 느낌)
|
||||||
|
float[] rows = { -16f, -8f, 8f, 16f };
|
||||||
|
const float rackH = 2.5f, rackDepth = 1.5f, rackLen = 36f;
|
||||||
|
foreach (float x in rows)
|
||||||
|
{
|
||||||
|
string side = x < 0 ? "L" : "R";
|
||||||
|
MakeBox(racks, $"Rack_{side}{Mathf.Abs(x):0}",
|
||||||
|
new Vector3(x, rackH / 2, 0f),
|
||||||
|
new Vector3(rackDepth, rackH, rackLen), rackMat);
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorSceneManager.MarkSceneDirty(root.scene);
|
||||||
|
Selection.activeGameObject = root;
|
||||||
|
Debug.Log("[WarehouseSetup] 완료. Play 후 캐릭터로 통로를 돌아다녀 보세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GameObject ChildContainer(GameObject parent, string name)
|
||||||
|
{
|
||||||
|
var t = parent.transform.Find(name);
|
||||||
|
if (t != null) return t.gameObject;
|
||||||
|
var go = new GameObject(name);
|
||||||
|
go.transform.SetParent(parent.transform, false);
|
||||||
|
return go;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MakeBox(GameObject parent, string name, Vector3 pos, Vector3 scale, Material mat)
|
||||||
|
{
|
||||||
|
if (parent.transform.Find(name) != null) return; // 이미 있으면 건너뜀
|
||||||
|
|
||||||
|
var box = GameObject.CreatePrimitive(PrimitiveType.Cube); // BoxCollider 포함
|
||||||
|
box.name = name;
|
||||||
|
box.transform.SetParent(parent.transform, false);
|
||||||
|
box.transform.localPosition = pos;
|
||||||
|
box.transform.localScale = scale;
|
||||||
|
box.GetComponent<Renderer>().sharedMaterial = mat;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Material MakeMaterial(Color color)
|
||||||
|
{
|
||||||
|
var shader = Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard");
|
||||||
|
return new Material(shader) { color = color };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e7789607004404110a0cbb35327f9a2c
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3ee476866b1bf47f5a10539f8b8dcef5
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace SinoWms.Gameplay
|
||||||
|
{
|
||||||
|
/// <summary>플레이어가 집어 옮길 수 있는 물건(박스/팔레트) 표시 컴포넌트.</summary>
|
||||||
|
[RequireComponent(typeof(Rigidbody))]
|
||||||
|
[RequireComponent(typeof(Collider))]
|
||||||
|
public class Carryable : MonoBehaviour
|
||||||
|
{
|
||||||
|
[Tooltip("힌트/로그에 표시할 이름. 비우면 오브젝트 이름 사용.")]
|
||||||
|
public string displayName;
|
||||||
|
|
||||||
|
public string Label => string.IsNullOrEmpty(displayName) ? name : displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 315a7e23f649b4187a444b01f14a8e9d
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.SceneManagement;
|
||||||
|
using UnityEngine.InputSystem;
|
||||||
|
|
||||||
|
namespace SinoWms.Gameplay
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 씬의 모든 적치 슬롯을 감시해 미션 진행/완료를 판정한다.
|
||||||
|
/// 모든 슬롯이 정답으로 채워지면 완료 처리하고 점수(정답·오답·시간)를 표시한다.
|
||||||
|
/// </summary>
|
||||||
|
public class MissionManager : MonoBehaviour
|
||||||
|
{
|
||||||
|
[SerializeField] private float targetSeconds = 60f; // 시간 보너스 기준
|
||||||
|
private const int ScorePerSlot = 100;
|
||||||
|
private const int MistakePenalty = 25;
|
||||||
|
|
||||||
|
private PutawaySlot[] _slots;
|
||||||
|
private float _startTime;
|
||||||
|
private int _mistakes;
|
||||||
|
private bool _completed;
|
||||||
|
private int _finalScore;
|
||||||
|
private float _elapsedAtComplete;
|
||||||
|
|
||||||
|
private GUIStyle _hudStyle;
|
||||||
|
private GUIStyle _panelStyle;
|
||||||
|
|
||||||
|
private void Start()
|
||||||
|
{
|
||||||
|
_slots = Object.FindObjectsByType<PutawaySlot>(FindObjectsSortMode.None);
|
||||||
|
_startTime = Time.time;
|
||||||
|
foreach (var s in _slots)
|
||||||
|
s.Changed += OnSlotChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDestroy()
|
||||||
|
{
|
||||||
|
if (_slots == null) return;
|
||||||
|
foreach (var s in _slots)
|
||||||
|
if (s != null) s.Changed -= OnSlotChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Update()
|
||||||
|
{
|
||||||
|
if (_completed && (Keyboard.current?.rKey.wasPressedThisFrame ?? false))
|
||||||
|
SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSlotChanged(PutawaySlot slot)
|
||||||
|
{
|
||||||
|
if (_completed) return;
|
||||||
|
|
||||||
|
// 오답 적치는 누적 집계(교육 감점용)
|
||||||
|
if (slot.IsOccupied && !slot.IsCorrect)
|
||||||
|
_mistakes++;
|
||||||
|
|
||||||
|
int correct = 0;
|
||||||
|
foreach (var s in _slots)
|
||||||
|
if (s.IsCorrect) correct++;
|
||||||
|
|
||||||
|
if (correct == _slots.Length)
|
||||||
|
Complete(correct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Complete(int correct)
|
||||||
|
{
|
||||||
|
_completed = true;
|
||||||
|
_elapsedAtComplete = Time.time - _startTime;
|
||||||
|
|
||||||
|
int score = correct * ScorePerSlot - _mistakes * MistakePenalty;
|
||||||
|
if (_elapsedAtComplete < targetSeconds)
|
||||||
|
score += Mathf.RoundToInt((1f - _elapsedAtComplete / targetSeconds) * 50f);
|
||||||
|
_finalScore = Mathf.Max(0, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int CorrectCount()
|
||||||
|
{
|
||||||
|
int c = 0;
|
||||||
|
foreach (var s in _slots)
|
||||||
|
if (s != null && s.IsCorrect) c++;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGUI()
|
||||||
|
{
|
||||||
|
_hudStyle ??= new GUIStyle(GUI.skin.box) { fontSize = 18, alignment = TextAnchor.MiddleCenter };
|
||||||
|
_panelStyle ??= new GUIStyle(GUI.skin.box) { fontSize = 22, alignment = TextAnchor.MiddleCenter };
|
||||||
|
|
||||||
|
if (_slots == null || _slots.Length == 0) return;
|
||||||
|
|
||||||
|
if (!_completed)
|
||||||
|
{
|
||||||
|
string hud = $"적치 {CorrectCount()}/{_slots.Length} 오답 {_mistakes}";
|
||||||
|
GUI.Label(new Rect(Screen.width / 2f - 130f, 64f, 260f, 32f), hud, _hudStyle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
float w = 360f, h = 160f;
|
||||||
|
var rect = new Rect(Screen.width / 2f - w / 2f, Screen.height / 2f - h / 2f, w, h);
|
||||||
|
string text =
|
||||||
|
$"미션 완료!\n\n점수 {_finalScore}점\n" +
|
||||||
|
$"소요 {_elapsedAtComplete:0}초 · 오답 {_mistakes}건\n\nR: 다시 하기";
|
||||||
|
GUI.Label(rect, text, _panelStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 27f48d56c8f68491ba5b07b4887165cc
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.InputSystem;
|
||||||
|
using SinoWms.Gameplay;
|
||||||
|
|
||||||
|
namespace SinoWms.Player
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 캐릭터의 집기/놓기 상호작용. 범위 내 가장 가까운 Carryable을 E키로 집어
|
||||||
|
/// 앞쪽 홀드 포인트에 들고 다니고, 다시 E키로 내려놓는다.
|
||||||
|
/// </summary>
|
||||||
|
public class PickupController : MonoBehaviour
|
||||||
|
{
|
||||||
|
[SerializeField] private float pickupRange = 2.5f;
|
||||||
|
[SerializeField] private Transform holdPoint;
|
||||||
|
|
||||||
|
private Carryable _carried;
|
||||||
|
private Carryable _target; // 현재 집을 수 있는 후보(힌트 표시용)
|
||||||
|
private PutawaySlot _targetSlot; // 들고 있을 때 적치 가능한 빈 슬롯
|
||||||
|
private GUIStyle _style;
|
||||||
|
|
||||||
|
private void Awake()
|
||||||
|
{
|
||||||
|
if (holdPoint == null)
|
||||||
|
{
|
||||||
|
var hp = new GameObject("HoldPoint").transform;
|
||||||
|
hp.SetParent(transform, false);
|
||||||
|
hp.localPosition = new Vector3(0f, 0.6f, 0.9f);
|
||||||
|
holdPoint = hp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Update()
|
||||||
|
{
|
||||||
|
var kb = Keyboard.current;
|
||||||
|
if (kb == null) return;
|
||||||
|
|
||||||
|
if (_carried == null)
|
||||||
|
{
|
||||||
|
_target = FindNearest();
|
||||||
|
_targetSlot = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_targetSlot = FindNearestSlot();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kb.eKey.wasPressedThisFrame)
|
||||||
|
{
|
||||||
|
if (_carried != null) Drop();
|
||||||
|
else if (_target != null) PickUp(_target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Carryable FindNearest()
|
||||||
|
{
|
||||||
|
var hits = Physics.OverlapSphere(transform.position, pickupRange);
|
||||||
|
Carryable best = null;
|
||||||
|
float bestDist = float.MaxValue;
|
||||||
|
foreach (var h in hits)
|
||||||
|
{
|
||||||
|
var c = h.GetComponentInParent<Carryable>();
|
||||||
|
if (c == null || c == _carried) continue;
|
||||||
|
float d = Vector3.Distance(transform.position, c.transform.position);
|
||||||
|
if (d < bestDist) { bestDist = d; best = c; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PutawaySlot FindNearestSlot()
|
||||||
|
{
|
||||||
|
var hits = Physics.OverlapSphere(transform.position, pickupRange);
|
||||||
|
PutawaySlot best = null;
|
||||||
|
float bestDist = float.MaxValue;
|
||||||
|
foreach (var h in hits)
|
||||||
|
{
|
||||||
|
var s = h.GetComponentInParent<PutawaySlot>();
|
||||||
|
if (s == null || s.IsOccupied) continue;
|
||||||
|
float d = Vector3.Distance(transform.position, s.transform.position);
|
||||||
|
if (d < bestDist) { bestDist = d; best = s; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PickUp(Carryable c)
|
||||||
|
{
|
||||||
|
// 슬롯에 적치돼 있던 박스라면 슬롯을 비운다
|
||||||
|
var fromSlot = c.GetComponentInParent<PutawaySlot>();
|
||||||
|
if (fromSlot != null && fromSlot.Occupant == c) fromSlot.Clear();
|
||||||
|
|
||||||
|
_carried = c;
|
||||||
|
_target = null;
|
||||||
|
|
||||||
|
if (c.TryGetComponent<Rigidbody>(out var rb)) rb.isKinematic = true;
|
||||||
|
if (c.TryGetComponent<Collider>(out var col)) col.enabled = false;
|
||||||
|
|
||||||
|
c.transform.SetParent(holdPoint);
|
||||||
|
c.transform.localPosition = Vector3.zero;
|
||||||
|
c.transform.localRotation = Quaternion.identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Drop()
|
||||||
|
{
|
||||||
|
var c = _carried;
|
||||||
|
_carried = null;
|
||||||
|
c.transform.SetParent(null);
|
||||||
|
|
||||||
|
// 근처 빈 슬롯이 있으면 적치(스냅 + 정답 판정)
|
||||||
|
if (_targetSlot != null && _targetSlot.TryPlace(c))
|
||||||
|
{
|
||||||
|
_targetSlot = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 아니면 그냥 바닥에 내려놓기
|
||||||
|
c.transform.position = holdPoint.position;
|
||||||
|
c.transform.rotation = holdPoint.rotation;
|
||||||
|
if (c.TryGetComponent<Collider>(out var col)) col.enabled = true;
|
||||||
|
if (c.TryGetComponent<Rigidbody>(out var rb)) rb.isKinematic = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGUI()
|
||||||
|
{
|
||||||
|
_style ??= new GUIStyle(GUI.skin.box) { fontSize = 18, alignment = TextAnchor.MiddleCenter };
|
||||||
|
|
||||||
|
string msg = null;
|
||||||
|
if (_carried != null)
|
||||||
|
msg = _targetSlot != null
|
||||||
|
? $"E: 적치 — {_targetSlot.Title}"
|
||||||
|
: $"E: 내려놓기 — {_carried.Label}";
|
||||||
|
else if (_target != null)
|
||||||
|
msg = $"E: 집기 — {_target.Label}";
|
||||||
|
if (msg == null) return;
|
||||||
|
|
||||||
|
GUI.Label(new Rect(Screen.width / 2f - 150f, 24f, 300f, 32f), msg, _style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8525e0c0e962c47b78e49a7e3371fe97
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace SinoWms.Gameplay
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 적치 슬롯. 캐릭터가 들고 온 Carryable을 받아 스냅 배치하고,
|
||||||
|
/// 기대 품목(expectedLabel)과 비교해 정답/오답을 바닥 패드 색으로 표시한다.
|
||||||
|
/// </summary>
|
||||||
|
public class PutawaySlot : MonoBehaviour
|
||||||
|
{
|
||||||
|
[Tooltip("이 슬롯에 와야 하는 품목 라벨. 비우면 아무거나 정답 처리.")]
|
||||||
|
public string expectedLabel;
|
||||||
|
|
||||||
|
[Tooltip("바닥 표시용 패드 렌더러(색으로 상태 표시).")]
|
||||||
|
[SerializeField] private Renderer pad;
|
||||||
|
|
||||||
|
[Tooltip("박스가 스냅될 패드 위 높이.")]
|
||||||
|
[SerializeField] private float dropHeight = 0.5f;
|
||||||
|
|
||||||
|
private static readonly Color Empty = new Color(1f, 0.82f, 0.3f);
|
||||||
|
private static readonly Color Correct = new Color(0.3f, 0.8f, 0.4f);
|
||||||
|
private static readonly Color Wrong = new Color(0.85f, 0.3f, 0.3f);
|
||||||
|
|
||||||
|
/// <summary>적치/제거 등 상태가 바뀌면 발생(미션 판정용).</summary>
|
||||||
|
public event System.Action<PutawaySlot> Changed;
|
||||||
|
|
||||||
|
public Carryable Occupant { get; private set; }
|
||||||
|
public bool IsOccupied => Occupant != null;
|
||||||
|
public bool IsCorrect =>
|
||||||
|
IsOccupied && (string.IsNullOrEmpty(expectedLabel) || Occupant.Label == expectedLabel);
|
||||||
|
|
||||||
|
public string Title => string.IsNullOrEmpty(expectedLabel) ? name : expectedLabel;
|
||||||
|
|
||||||
|
private void Start() => UpdateMarker();
|
||||||
|
|
||||||
|
public bool TryPlace(Carryable c)
|
||||||
|
{
|
||||||
|
if (IsOccupied) return false;
|
||||||
|
Occupant = c;
|
||||||
|
|
||||||
|
c.transform.SetParent(transform);
|
||||||
|
c.transform.localPosition = new Vector3(0f, dropHeight, 0f);
|
||||||
|
c.transform.localRotation = Quaternion.identity;
|
||||||
|
if (c.TryGetComponent<Collider>(out var col)) col.enabled = true;
|
||||||
|
if (c.TryGetComponent<Rigidbody>(out var rb)) rb.isKinematic = true;
|
||||||
|
|
||||||
|
UpdateMarker();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
Occupant = null;
|
||||||
|
UpdateMarker();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateMarker()
|
||||||
|
{
|
||||||
|
if (pad == null) return;
|
||||||
|
pad.material.color = !IsOccupied ? Empty : (IsCorrect ? Correct : Wrong);
|
||||||
|
Changed?.Invoke(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c9d533b08ac4a487182866db79a8e2e3
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5b49d1f2603cb4c10b66f082dc3bc79a
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -34,6 +34,13 @@ namespace SinoWms.Inbound
|
|||||||
private set { _step = value; StepChanged?.Invoke(value); }
|
private set { _step = value; StepChanged?.Invoke(value); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>로더 등 외부에서 만든 시나리오로 미션 시작(인스펙터 할당 대신).</summary>
|
||||||
|
public void StartMission(InboundScenario scenarioToRun)
|
||||||
|
{
|
||||||
|
scenario = scenarioToRun;
|
||||||
|
StartMission();
|
||||||
|
}
|
||||||
|
|
||||||
public void StartMission()
|
public void StartMission()
|
||||||
{
|
{
|
||||||
if (scenario == null)
|
if (scenario == null)
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 497165b3023884a76924a48c1ec882c2
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 94216e0f247b24166ad4130ff894e14d
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3dc005f73395b4717bb18d90ec3e4b23
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 26c6b07a437d04231831951b4c4c09b7
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0776ba8b91281473a803a1975c135c1f
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 7891d41cf22544f878fa7dee0c7cc07a
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace SinoWms.Player
|
||||||
|
{
|
||||||
|
/// <summary>대상(캐릭터)을 위에서 비스듬히 따라다니는 탑다운 카메라.</summary>
|
||||||
|
public class CameraFollow : MonoBehaviour
|
||||||
|
{
|
||||||
|
[SerializeField] private Transform target;
|
||||||
|
[SerializeField] private Vector3 offset = new Vector3(0f, 12f, -8f);
|
||||||
|
[SerializeField] private float smooth = 8f;
|
||||||
|
|
||||||
|
public void SetTarget(Transform t) => target = t;
|
||||||
|
|
||||||
|
private void LateUpdate()
|
||||||
|
{
|
||||||
|
if (target == null) return;
|
||||||
|
|
||||||
|
Vector3 desired = target.position + offset;
|
||||||
|
transform.position = Vector3.Lerp(transform.position, desired, smooth * Time.deltaTime);
|
||||||
|
transform.LookAt(target.position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 378ab13c02a4c4074878e994d1593434
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.InputSystem;
|
||||||
|
|
||||||
|
namespace SinoWms.Player
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 탑다운 캐릭터 이동. 새 Input System(Keyboard.current)으로 WASD/방향키 입력을 받아
|
||||||
|
/// XZ 평면을 이동하고, 진행 방향으로 부드럽게 회전한다.
|
||||||
|
/// </summary>
|
||||||
|
[RequireComponent(typeof(CharacterController))]
|
||||||
|
public class PlayerController : MonoBehaviour
|
||||||
|
{
|
||||||
|
[SerializeField] private float moveSpeed = 5f;
|
||||||
|
[SerializeField] private float turnSpeed = 12f;
|
||||||
|
[SerializeField] private float gravity = -20f;
|
||||||
|
|
||||||
|
private CharacterController _cc;
|
||||||
|
private float _verticalVelocity;
|
||||||
|
|
||||||
|
private void Awake()
|
||||||
|
{
|
||||||
|
_cc = GetComponent<CharacterController>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Update()
|
||||||
|
{
|
||||||
|
var kb = Keyboard.current;
|
||||||
|
if (kb == null) return;
|
||||||
|
|
||||||
|
Vector2 input = Vector2.zero;
|
||||||
|
if (kb.wKey.isPressed || kb.upArrowKey.isPressed) input.y += 1f;
|
||||||
|
if (kb.sKey.isPressed || kb.downArrowKey.isPressed) input.y -= 1f;
|
||||||
|
if (kb.dKey.isPressed || kb.rightArrowKey.isPressed) input.x += 1f;
|
||||||
|
if (kb.aKey.isPressed || kb.leftArrowKey.isPressed) input.x -= 1f;
|
||||||
|
|
||||||
|
Vector3 move = new Vector3(input.x, 0f, input.y);
|
||||||
|
if (move.sqrMagnitude > 1f) move.Normalize();
|
||||||
|
|
||||||
|
// 중력 (바닥에 붙어있게)
|
||||||
|
if (_cc.isGrounded && _verticalVelocity < 0f) _verticalVelocity = -2f;
|
||||||
|
_verticalVelocity += gravity * Time.deltaTime;
|
||||||
|
|
||||||
|
Vector3 velocity = move * moveSpeed;
|
||||||
|
velocity.y = _verticalVelocity;
|
||||||
|
_cc.Move(velocity * Time.deltaTime);
|
||||||
|
|
||||||
|
// 진행 방향으로 회전
|
||||||
|
if (move.sqrMagnitude > 0.01f)
|
||||||
|
{
|
||||||
|
Quaternion target = Quaternion.LookRotation(move, Vector3.up);
|
||||||
|
transform.rotation = Quaternion.Slerp(transform.rotation, target, turnSpeed * Time.deltaTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 00269464ac30a416992064182bd7186c
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 09512757295aa4e849a36067192a5348
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 88d854ac9c88045d5b950fb2704463e8
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
%YAML 1.1
|
||||||
|
%TAG !u! tag:unity3d.com,2011:
|
||||||
|
--- !u!114 &11400000
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 0}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 19101, guid: 0000000000000000e000000000000000, type: 0}
|
||||||
|
m_Name: InboundPanelSettings
|
||||||
|
m_EditorClassIdentifier: UnityEngine.dll::UnityEngine.UIElements.PanelSettings
|
||||||
|
themeUss: {fileID: -4733365628477956816, guid: 874e1b7e4085a4bb1b379902fbefadc2, type: 3}
|
||||||
|
m_DisableNoThemeWarning: 0
|
||||||
|
m_TargetTexture: {fileID: 0}
|
||||||
|
m_RenderMode: 0
|
||||||
|
m_ColliderUpdateMode: 0
|
||||||
|
m_ColliderIsTrigger: 1
|
||||||
|
m_ScaleMode: 1
|
||||||
|
m_ReferenceSpritePixelsPerUnit: 100
|
||||||
|
m_PixelsPerUnit: 100
|
||||||
|
m_Scale: 1
|
||||||
|
m_ReferenceDpi: 96
|
||||||
|
m_FallbackDpi: 96
|
||||||
|
m_ReferenceResolution: {x: 1200, y: 800}
|
||||||
|
m_ScreenMatchMode: 0
|
||||||
|
m_Match: 0
|
||||||
|
m_SortingOrder: 0
|
||||||
|
m_TargetDisplay: 0
|
||||||
|
m_BindingLogLevel: 0
|
||||||
|
m_ClearDepthStencil: 1
|
||||||
|
m_ClearColor: 0
|
||||||
|
m_ColorClearValue: {r: 0, g: 0, b: 0, a: 0}
|
||||||
|
m_VertexBudget: 0
|
||||||
|
m_TextureSlotCount: 8
|
||||||
|
m_DynamicAtlasSettings:
|
||||||
|
m_MinAtlasSize: 64
|
||||||
|
m_MaxAtlasSize: 4096
|
||||||
|
m_MaxSubTextureSize: 64
|
||||||
|
m_ActiveFilters: 31
|
||||||
|
m_AtlasBlitShader: {fileID: 9101, guid: 0000000000000000f000000000000000, type: 0}
|
||||||
|
m_DefaultShader: {fileID: 9100, guid: 0000000000000000f000000000000000, type: 0}
|
||||||
|
m_RuntimeGaussianBlurShader: {fileID: 20300, guid: 0000000000000000f000000000000000, type: 0}
|
||||||
|
m_RuntimeColorEffectShader: {fileID: 20301, guid: 0000000000000000f000000000000000, type: 0}
|
||||||
|
m_SDFShader: {fileID: 19011, guid: 0000000000000000f000000000000000, type: 0}
|
||||||
|
m_BitmapShader: {fileID: 9001, guid: 0000000000000000f000000000000000, type: 0}
|
||||||
|
m_SpriteShader: {fileID: 19012, guid: 0000000000000000f000000000000000, type: 0}
|
||||||
|
m_ICUDataAsset: {fileID: 0}
|
||||||
|
forceGammaRendering: 0
|
||||||
|
textSettings: {fileID: 0}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0e19a4aaa28b74028a362e66ff3771e5
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
/* SINO WMS 입고 교육 — PDA/창고 UI 스타일 (mockup/inbound_pda.html 이식) */
|
||||||
|
|
||||||
|
.root {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 24px;
|
||||||
|
background-color: rgb(31, 36, 48);
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
color: rgb(230, 233, 239);
|
||||||
|
-unity-font-style: bold;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
color: rgb(139, 147, 167);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 탑다운 창고 ---- */
|
||||||
|
.warehouse {
|
||||||
|
background-color: rgb(39, 45, 58);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
margin-right: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-row {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc {
|
||||||
|
width: 64px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 4px;
|
||||||
|
border-width: 2px;
|
||||||
|
border-color: rgba(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-code {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgb(230, 233, 239);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-cap {
|
||||||
|
font-size: 9px;
|
||||||
|
color: rgb(170, 178, 197);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient { background-color: rgb(58, 66, 86); }
|
||||||
|
.chilled { background-color: rgb(30, 74, 95); }
|
||||||
|
.frozen { background-color: rgb(30, 58, 111); }
|
||||||
|
|
||||||
|
.loc--selectable {
|
||||||
|
border-color: rgb(110, 168, 254);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc--filled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
flex-direction: row;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item Label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgb(139, 147, 167);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-ambient { background-color: rgb(58, 66, 86); }
|
||||||
|
.dot-chilled { background-color: rgb(30, 74, 95); }
|
||||||
|
.dot-frozen { background-color: rgb(30, 58, 111); }
|
||||||
|
|
||||||
|
/* ---- PDA ---- */
|
||||||
|
.pda {
|
||||||
|
width: 300px;
|
||||||
|
background-color: rgb(17, 21, 28);
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 16px;
|
||||||
|
border-width: 8px;
|
||||||
|
border-color: rgb(10, 13, 18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pda-screen {
|
||||||
|
background-color: rgb(244, 246, 251);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-bar {
|
||||||
|
flex-direction: row;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-seg {
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-right: 4px;
|
||||||
|
background-color: rgb(211, 217, 230);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-seg--on {
|
||||||
|
background-color: rgb(43, 111, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgb(43, 111, 255);
|
||||||
|
-unity-font-style: bold;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-head {
|
||||||
|
font-size: 17px;
|
||||||
|
color: rgb(31, 36, 48);
|
||||||
|
-unity-font-style: bold;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-card {
|
||||||
|
background-color: rgb(255, 255, 255);
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: rgb(227, 232, 242);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-card .name {
|
||||||
|
-unity-font-style: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgb(31, 36, 48);
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-card .meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgb(107, 114, 128);
|
||||||
|
margin-top: 2px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding-left: 6px;
|
||||||
|
padding-right: 6px;
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.b-ambient { background-color: rgb(231, 236, 251); color: rgb(43, 78, 255); }
|
||||||
|
.b-chilled { background-color: rgb(223, 241, 246); color: rgb(12, 116, 145); }
|
||||||
|
.b-frozen { background-color: rgb(223, 235, 255); color: rgb(30, 58, 111); }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
padding-top: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-width: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: rgb(43, 111, 255);
|
||||||
|
color: rgb(255, 255, 255);
|
||||||
|
font-size: 14px;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
margin-top: 6px;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Button.ghost {
|
||||||
|
background-color: rgb(238, 241, 248);
|
||||||
|
color: rgb(43, 111, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField, IntegerField {
|
||||||
|
margin-top: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-ok { background-color: rgb(227, 247, 234); color: rgb(26, 127, 67); }
|
||||||
|
.fb-warn { background-color: rgb(253, 236, 236); color: rgb(192, 57, 43); }
|
||||||
|
|
||||||
|
.result-score {
|
||||||
|
font-size: 42px;
|
||||||
|
-unity-font-style: bold;
|
||||||
|
-unity-text-align: middle-center;
|
||||||
|
color: rgb(43, 111, 255);
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.err-item {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgb(192, 57, 43);
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 075aa5cc7aa18459b912d40ea7c9410b
|
||||||
|
ScriptedImporter:
|
||||||
|
internalIDToNameTable: []
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
|
||||||
|
disableValidation: 0
|
||||||
|
unsupportedSelectorAction: 0
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<ui:UXML xmlns:ui="UnityEngine.UIElements">
|
||||||
|
<Style src="InboundPda.uss" />
|
||||||
|
<ui:VisualElement class="root">
|
||||||
|
|
||||||
|
<!-- 탑다운 창고 -->
|
||||||
|
<ui:VisualElement class="warehouse">
|
||||||
|
<ui:Label text="창고 (탑다운 뷰)" class="h1" />
|
||||||
|
<ui:Label text="로케이션을 클릭해 적치합니다" class="sub" />
|
||||||
|
<ui:VisualElement name="grid" class="grid" />
|
||||||
|
<ui:VisualElement class="legend">
|
||||||
|
<ui:VisualElement class="legend-item">
|
||||||
|
<ui:VisualElement class="dot dot-ambient" />
|
||||||
|
<ui:Label text="상온" />
|
||||||
|
</ui:VisualElement>
|
||||||
|
<ui:VisualElement class="legend-item">
|
||||||
|
<ui:VisualElement class="dot dot-chilled" />
|
||||||
|
<ui:Label text="냉장" />
|
||||||
|
</ui:VisualElement>
|
||||||
|
<ui:VisualElement class="legend-item">
|
||||||
|
<ui:VisualElement class="dot dot-frozen" />
|
||||||
|
<ui:Label text="냉동" />
|
||||||
|
</ui:VisualElement>
|
||||||
|
</ui:VisualElement>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
<!-- PDA -->
|
||||||
|
<ui:VisualElement class="pda-wrap">
|
||||||
|
<ui:Label text="PDA — 입고 교육" class="h1" />
|
||||||
|
<ui:Label name="scenario-sub" text="" class="sub" />
|
||||||
|
<ui:VisualElement class="pda">
|
||||||
|
<ui:VisualElement class="pda-screen">
|
||||||
|
<ui:VisualElement name="stepbar" class="step-bar" />
|
||||||
|
<ui:VisualElement name="content" />
|
||||||
|
<ui:VisualElement name="feedback" />
|
||||||
|
</ui:VisualElement>
|
||||||
|
</ui:VisualElement>
|
||||||
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
</ui:VisualElement>
|
||||||
|
</ui:UXML>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: cd2a30127b39b47f9847c26859259fa6
|
||||||
|
ScriptedImporter:
|
||||||
|
internalIDToNameTable: []
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import url("unity-theme://default");
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 874e1b7e4085a4bb1b379902fbefadc2
|
||||||
|
ScriptedImporter:
|
||||||
|
internalIDToNameTable: []
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
script: {fileID: 12388, guid: 0000000000000000e000000000000000, type: 0}
|
||||||
|
disableValidation: 0
|
||||||
|
unsupportedSelectorAction: 0
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 변경 이력 (Changelog)
|
||||||
|
|
||||||
|
이 프로젝트의 모든 주요 변경 사항을 이 파일에 기록합니다.
|
||||||
|
|
||||||
|
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||||
|
버전 표기는 [유의적 버전(SemVer)](https://semver.org/lang/ko/)을 따릅니다.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
### 추가됨 (Added)
|
||||||
|
- `ScenarioLoader` — JSON 시나리오/창고 레이아웃을 런타임 ScriptableObject로 변환
|
||||||
|
- 창고 레이아웃 데이터 `Assets/Resources/Scenarios/warehouse_basic.json` (로케이션 마스터)
|
||||||
|
- `InboundManager.StartMission(InboundScenario)` 오버로드 (로더 결과 주입용)
|
||||||
|
- 입고 로직 스모크 테스트 (`SinoWMS/Run Inbound Smoke Test` 메뉴 — 해피패스 + 함정 4종 검증)
|
||||||
|
- PDA + 탑다운 창고 UI (UI Toolkit): `InboundPda.uxml` / `InboundPda.uss` / `InboundUIController`
|
||||||
|
- 씬 자동 구성 메뉴 `SinoWMS/Setup Inbound Scene` (UIDocument/PanelSettings 와이어링)
|
||||||
|
|
||||||
|
### 예정
|
||||||
|
- 로케이션 슬롯 프리팹 + 적치 애니메이션
|
||||||
|
- 채점 결과 화면 폴리시
|
||||||
|
- 2번째 모듈(피킹)
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-06-14
|
||||||
|
### 추가됨 (Added)
|
||||||
|
- 입고(Inbound) 모듈 코드 골격
|
||||||
|
- Core: `InboundStep` (미션 단계 enum)
|
||||||
|
- Data: `StorageZone`, `ItemDefinition`, `LocationDefinition`, `InboundScenario` (ScriptableObject)
|
||||||
|
- Inbound: `LineProgress`, `ScanService`, `PutawayService`, `ScoringSystem`, `InboundManager`
|
||||||
|
- 샘플 시나리오 `Assets/Resources/Scenarios/inbound_basic.json` (수량 차이 함정 포함)
|
||||||
|
- PDA 목업 `mockup/inbound_pda.html`
|
||||||
|
- 설계 문서 `docs/design.md`
|
||||||
|
- 프로젝트 README
|
||||||
|
|
||||||
|
[Unreleased]: https://keepachangelog.com/ko/1.1.0/
|
||||||
|
[0.1.0]: https://keepachangelog.com/ko/1.1.0/
|
||||||
@@ -13,6 +13,7 @@ MonoBehaviour:
|
|||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier:
|
m_EditorClassIdentifier:
|
||||||
shaderVariantLimit: 128
|
shaderVariantLimit: 128
|
||||||
|
overrideShaderVariantLimit: 0
|
||||||
customInterpolatorErrorThreshold: 32
|
customInterpolatorErrorThreshold: 32
|
||||||
customInterpolatorWarningThreshold: 16
|
customInterpolatorWarningThreshold: 16
|
||||||
customHeatmapValues: {fileID: 0}
|
customHeatmapValues: {fileID: 0}
|
||||||
|
|||||||
Reference in New Issue
Block a user