Spec_Elem_03 — Hidden Yarn (Len Ẩn Màu)
Mục tiêu: Spec triển khai cho agent. Tuân thủ kiến trúc hiện có: Data → Factory → Domain → Visual, event-driven (KHÔNG dùng
IEntityDomain.Tick— interface tồn tại nhưng chưa wire ở đâu).Level unlock: 15 · Element id:
Spec_Elem_03Tham chiếu gameplay: https://www.youtube.com/watch?v=Iw0zHT67pek
| # | Hạng mục | Quyết định |
|---|---|---|
| 1 | Bản chất | Hidden Yarn KHÔNG phải loại ball mới. Nó là modifier gắn lên một WoolBall thường: ban đầu che màu (xám + dấu ?), khi đủ điều kiện thì lộ màu thật và trở lại WoolBall bình thường hoàn toàn. |
| 2 | Kiến trúc (theo yêu cầu) | HiddenYarnFactory riêng. Domain HiddenYarn được AddComponent vào CÙNG GameObject đang chứa domain WoolBall (companion component, KHÔNG tạo GO mới). |
| 3 | Visual split | Trong GameObject ball có 2 visual tách biệt: WoolBallVisual (default, màu thật) và HiddenYarnVisual (xám + ?). Domain HiddenYarn bật/tắt để switch giữa 2 trạng thái — KHÔNG destroy/recreate. |
| 4 | Điều kiện áp dụng (authoring) | Chỉ gắn được cho ball bị chặn (blocked) ngay từ đầu — lúc load level KHÔNG có path từ ball tới Gate. Ball thông sẵn từ đầu → editor chặn gắn element. |
| 5 | Gate = 1 exit duy nhất ✅ | Mỗi level chỉ có DUY NHẤT 1 exit tile (Level.targetExitTileId, Level.hasTargetExitTileId). "Thông đường tới Gate" = tồn tại path từ ball tới đúng exit đó. Dùng thẳng WoolBall.CanMoveToTarget() (đã có sẵn, check hasTargetExitTileId && CanMoveTo(targetExitTileId)). KHÔNG loop GetExitTiles(). |
| 6 | Điều kiện lộ màu (runtime) | Khi path ball→Gate thông → lộ màu thật. Reveal là một chiều (đã lộ thì không ẩn lại dù sau đó chặn lại). |
| 7 | Re-evaluate = EVENT ✅ | KHÔNG Update poll. HiddenYarn đăng ký callback YarnBoardRuntimeState.OnBoardChanged (raise sau mỗi BuildTrainConnections() + sau Unregister). Mỗi lần board đổi → TryReveal(). Xem §3.4. |
| 8 | Khoá tương tác khi ẩn ✅ | Trong lúc còn ẩn, ball KHÔNG cho click/chọn/train/merge/dispatch. Occupancy & pathfinding (vật cản) vẫn giữ nguyên (ball vẫn chặn đường như bình thường). Khoá qua WoolBall.SetInteractionLock(true) + loại khỏi train-link building. Reveal → mở khoá. Xem §3.5. |
| 9 | Sau khi lộ | Ball thành WoolBall thường 100%: click/train/merge/dispatch như cũ. HiddenYarn tự gỡ vai trò (enabled=false + unsubscribe event), HiddenYarnVisual tắt hẳn. |
| 10 | Asset visual ẩn ✅ | Thêm hiddenYarnMaterial (+ asset dấu ?) vào PrefabProfile để art kiểm soát. KHÔNG clone material runtime. Xem §4.1. |
| 11 | Editor UI | Toggle trong inspector của Yarn Ball đang chọn (KHÔNG tạo tool/tab riêng). Tap chọn ball → bật/tắt "Hidden Yarn". |
Các quyết định ✅ là 4 điểm vừa chốt qua Q&A; phần còn lại đã chốt từ trước. Mọi điểm tunable nhỏ còn lại nằm ở §7.
[Gate/Exit duy nhất] ──X── (tường/ball chặn) ──── ● = HiddenYarn (xám, "?", KHOÁ tương tác)
│
●(?) ●(?) ●(?) ←── các ball bị chặn, màu bị giấu, không click được
│
player clear bớt vật cản (ở các ball KHÁC) → mở đường tới Gate
▼
●(Red) ●(Blue) ●(Green) ←── lộ màu thật + mở khoá ngay khi path thông
- Level load: với mỗi
WoolBallDatacó cờisHiddenYarn = true, sau khiWoolBallFactorytạo ball xong,HiddenYarnFactorygắnHiddenYarnlên cùng GameObject và bật chế độ ẩn: hiệnHiddenYarnVisual(xám +?), ẩn pieces màu thật củaWoolBallVisual, khoá tương tác ball. - Ball ẩn vẫn register occupancy & là vật cản trong pathfinding (KHÔNG đổi luật đi).
ColorIdthật vẫn nằm trongWoolBallData, chỉ bị giấu khỏi hiển thị và khoá thao tác. - Mỗi khi board đổi (player clear/move/dispatch ball khác →
BuildTrainConnections()hoặcUnregisterchạy →OnBoardChangedraise),HiddenYarnre-evaluate: ball này hiện có path tới Gate chưa (woolBall.CanMoveToTarget())?- Chưa thông → giữ ẩn + giữ khoá.
- Đã thông → PlayReveal(): tắt
HiddenYarnVisual, hiện pieces màu thật, mở khoá tương tác, hiệu ứng lộ màu. Sau đóHiddenYarnngừng vai trò (disable + unsubscribe).
- Sau reveal, ball là WoolBall thường hoàn toàn.
Invariant chính: reveal một chiều & dựa thuần vào CanMoveToTarget() tại thời điểm board ổn định (sau BuildTrainConnections). Agent KHÔNG để trạng thái nhấp nháy ẩn↔hiện.
Thêm cờ (default false để JSON cũ deserialize an toàn — JsonUtility bỏ field thiếu → false):
[System.Serializable]
public class WoolBallData
{
public int BallId;
public int ColorId;
public Vector2Int tileId;
public List<Vector2Int> childrenTileIds;
public bool isHiddenYarn; // ← MỚI: ball khởi tạo ở trạng thái ẩn màu
}
⚠️ Color convention:WoolBallData.ColorIdlà palette index (xemWoolBallVisual.cs:ColorsParamSO.GetColorByPaletteIndex(data.ColorId)). Hidden Yarn KHÔNG đổiColorId— chỉ giấu hiển thị. Khi reveal,WoolBallVisualdùng đúngColorIdđó.
Đã verify: YarnBoardEditorWindow.Serialization.cs gán nguyên list yarnBalls = level.Balls (dòng ~169) chứ KHÔNG copy field-by-field. JsonUtility serialize cả object WoolBallData ⇒ isHiddenYarn tự được lưu/đọc. Không cần thêm list mới vào LevelData (khác cố ý với YarnTube). Chỉ cần đảm bảo không có chỗ nào dựng new WoolBallData{...} thủ công làm rớt field — hiện không có.
3.1. Elements/Domain/HiddenYarn.cs (mới) — companion component
Khuôn WoolBall/YarnTube: MonoBehaviour, IRuntimeCreatable, IPendingCleanup. Sống chung GameObject với WoolBall.
[RequireComponent(typeof(WoolBall))]
public class HiddenYarn : MonoBehaviour, IRuntimeCreatable, IPendingCleanup
{
public bool IsHidden { get; private set; }
public bool IsRevealed => !IsHidden;
public bool IsPendingCleanup { get; private set; }
private WoolBall woolBall; // cùng GO
private YarnBoardRuntimeState runtimeState;
private BoardSplineDataAdapterInfo adapter;
private HiddenYarnVisual hiddenVisual; // visual xám + "?"
private bool revealInFlight;
private bool subscribed;
}OnCreated(ICreateParameters) — cast sang HiddenYarnCreateParameters:
woolBall = GetComponent<WoolBall>();(BẮT BUỘC tồn tại — gắn sau khi ball tạo xong).- gán
runtimeState,adaptertừ params (hoặcwoolBall.Adapter). - Tạo/lấy
HiddenYarnVisualở child riêng"HiddenVisual"(KHÔNG dùng chung child"Visual"của WoolBall — xem §4.2). EnterHiddenState():IsHidden = true;woolBall.Visual.SetAllPiecesVisible(false);(ẩn màu thật — API có sẵn, dòng ~427WoolBallVisual).hiddenVisual.Render(woolBall.Data, adapter); hiddenVisual.SetActive(true);woolBall.SetInteractionLock(true);(khoá click/train/merge — xem §3.5).
- Subscribe event:
runtimeState.OnBoardChanged += OnBoardChanged; subscribed = true;(xem §3.4). - Re-evaluate lần đầu:
TryReveal();(an toàn — nếu author đặt đúng thì vẫn ẩn vì blocked).
KHÔNG có Update(). Re-eval hoàn toàn event-driven (quyết định #7).
private void OnBoardChanged() => TryReveal();TryReveal() (cốt lõi):
private void TryReveal()
{
if (revealInFlight || IsRevealed || IsPendingCleanup) return;
if (woolBall == null || runtimeState == null) return;
if (!IsReachableToGate()) return; // chưa thông → giữ ẩn
revealInFlight = true;
PlayRevealAsync(this.GetCancellationTokenOnDestroy()).Forget();
}IsReachableToGate() — ✅ dùng API có sẵn (đã verify), KHÔNG tự viết BFS:
private bool IsReachableToGate()
{
// Level chỉ 1 exit → tái dùng nguyên CanMoveToTarget() của WoolBall.
// (nội bộ: hasTargetExitTileId && CanMoveTo(targetExitTileId) → TryFindPath head/tail)
return woolBall.CanMoveToTarget();
}
CanMoveToTarget()xét cả endpoint root & tail giống lúc ball di chuyển ⇒ nhất quán với luật move. KHÔNG cần xử lý multi-tile riêng.
PlayRevealAsync(ct) (UniTaskVoid):
IsHidden = false;await hiddenVisual.PlayRevealOut(ct);(fade dấu?+ ball xám) rồihiddenVisual.SetActive(false);woolBall.Visual.SetAllPiecesVisible(true);(hiện màu thật — pieces vẫn còn, chỉ bị ẩn; KHÔNG cần Render lại).woolBall.SetInteractionLock(false);(mở khoá → ball thành thường).- (tùy chọn)
woolBall.Visual.PlayEntranceScale();hoặc pop-scale nhấn "lộ màu". runtimeState.BuildTrainConnections();(rebuild links phòng reveal mở ra merge mới — idempotent).revealInFlight = false;- Gỡ vai trò:
UnsubscribeBoardChanged(); enabled = false;(disable, KHÔNG Destroy — an toàn nếu còn reference; tunable §7). - Bọc
try/catch (OperationCanceledException).
IPendingCleanup.CleanupForLevelUnload():
IsPendingCleanup = true;
UnsubscribeBoardChanged();
hiddenVisual?.StopAllTweens();UnsubscribeBoardChanged(): if (subscribed && runtimeState != null) { runtimeState.OnBoardChanged -= OnBoardChanged; subscribed = false; }. Gọi cả trong OnDestroy.
Tách bạch domain↔visual:
HiddenYarnKHÔNG truy cập mesh/material trực tiếp; mọi thứ quaHiddenYarnVisualAPI + API show/hide củaWoolBallVisual.
3.2. System/Creation/HiddenYarnCreateParameters.cs (thêm vào YarnBoardCreationParameters.cs)
public sealed class HiddenYarnCreateParameters : ICreateParameters
{
public HiddenYarnCreateParameters(
WoolBall woolBall,
BoardSplineDataAdapterInfo adapter,
YarnBoardRuntimeState runtimeState)
{
WoolBall = woolBall;
Adapter = adapter;
RuntimeState = runtimeState;
}
public WoolBall WoolBall { get; }
public BoardSplineDataAdapterInfo Adapter { get; }
public YarnBoardRuntimeState RuntimeState { get; }
}3.3. System/Creation/HiddenYarnFactory.cs (mới) — ✅ AddComponent, KHÔNG Instantiate
public class HiddenYarnFactory : IFactory<HiddenYarn>
{
public UniTask<HiddenYarn> Create(ICreateParameters parameters, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
if (parameters is not HiddenYarnCreateParameters p)
throw new System.ArgumentException($"Expected {nameof(HiddenYarnCreateParameters)}.");
var go = p.WoolBall.gameObject; // CÙNG GameObject với WoolBall
if (!go.TryGetComponent(out HiddenYarn hidden)) // chống double-create
hidden = go.AddComponent<HiddenYarn>();
hidden.OnCreated(p);
return UniTask.FromResult(hidden);
}
}Thêm 1 event công khai + raise tại các điểm board ổn định:
public event System.Action OnBoardChanged;
private void RaiseBoardChanged() => OnBoardChanged?.Invoke();Raise cuối các API làm thay đổi cục diện board (BẮT BUỘC đủ 3 điểm):
- Cuối
BuildTrainConnections()— tín hiệu chính khi graph được rebuild. - Cuối
Unregister(WoolBall)— khi 1 ball hoàn tất/biến mất (mở đường). - Cuối
EndTrainSession(session)✅ — gọiBuildTrainConnections()ở đây. ĐÂY LÀ ĐIỂM DỄ SÓT NHẤT.
⚠️ Tại sao bắt buộcEndTrainSession:BuildTrainConnections()chỉ chạy lúc bắt đầu 1 nước đi (WoolBall.OnInteract → RebuildTrainLinks, tức TRƯỚC khi ball di chuyển), vàUnregisterchỉ chạy khi ball Complete/biến mất. Một nước đi mà ball reposition hoặc đang chờ dispatch ở conveyor (chưa Complete) sẽ giải phóng ô cũ quaEndMoveOccupancy/SetOccupancymà KHÔNG raiseOnBoardChanged. Hệ quả: ball ẩn bị chặn bởi ô đó không bao giờ lộ dù đường đã thông.TrainMoveSession.Run()luôn gọiruntimeState.EndTrainSession(this)trongfinally(sau khi occupancy đã commit) ⇒ thêmBuildTrainConnections()tạiEndTrainSessionphủ trọn mọi nước đi. KHÔNG raise trongSetOccupancy/Registervì sẽ gây lộ nhầm giữa lúc spawn (occupancy chưa đầy đủ).
⚠️ Tránh reentrancy:PlayRevealAsyncgọiBuildTrainConnections()→ lại raiseOnBoardChanged. Lúc đóIsRevealed == truenênTryReveal()return ngay ⇒ không vòng lặp. Vẫn nên setIsHidden=falsetrước khi gọiBuildTrainConnections.
Khoá thuộc domain WoolBall (1 nguồn sự thật), HiddenYarn chỉ bật/tắt cờ.
Thêm vào Elements/Domain/WoolBall.cs:
public bool IsInteractionLocked { get; private set; }
public void SetInteractionLock(bool locked)
{
IsInteractionLocked = locked;
SetInteractionCollidersEnabled(!locked && !IsCompleted && !IsMoving); // method private sẵn có
}Chèn guard:
OnInteract(): thêmIsInteractionLockedvào đầu điều kiện return:if (IsInteractionLocked || IsMoving || IsCompleted || IsPendingCleanup || isMovingIntoEntrance || isDispatchingAtWait) return;CanBeClickedHead:=> !IsInteractionLocked && !trainCycleUnsafe && !HasPreviousTailChain();
Loại khỏi train/merge — phải chặn ở CẢ HAI tầng (nếu thiếu 1 → ball ẩn bị teleport theo train):
- Static train-link graph —
TrainGraphBuilder.BuildTrainConnections(): khi dựng tậpeligibleđể nối link, skip ballIsInteractionLocked⇒ ball ẩn không có train link nào. - Session join gate ✅ —
TrainSessionCoordinator.CanJoinTrainSession(ball): thêm!ball.IsInteractionLocked. ĐÂY LÀ ĐIỂM DỄ SÓT. Khi 1 ball cùng màu kề bên di chuyển,TrainMoveSession.EnsureFullColorComponentCollected()quét toàn bộ ball cùng màu kề footprint và ép chúng vào session (kể cả tạo synthetic SideMerge link), chỉ chặn bằngCanJoinTrainSession. Tầng (1) KHÔNG đủ vì path này bỏ qua static graph. Nếu thiếu, ball ẩn bị kéo vào train của ball kia và teleport tới conveyor entrance khi ball kia dispatch.
Vì ball ẩn luôn blocked (không path tới gate) nên thường không tự thao tác được; nhưng khoá tường minh ở cả 2 tầng đảm bảo đúng quyết định #8 khi ball ẩn đứng cạnh ball cùng màu đã thông và ball đó được click di chuyển/dispatch.
Trong vòng tạo yarnBalls (đoạn ~dòng 120-129): capture WoolBall trả về, nếu isHiddenYarn thì gắn Hidden Yarn.
private readonly HiddenYarnFactory hiddenYarnFactory = new();
...
WoolBall ball = await woolBallFactory.Create(new WoolBallCreateParameters(
ballData, adapter, boardRoot, runtimeState, activeConveyorEntrance), token);
if (ballData.isHiddenYarn)
{
await hiddenYarnFactory.Create(
new HiddenYarnCreateParameters(ball, adapter, runtimeState), token);
}Hiện
await woolBallFactory.Create(...)không capture return (dòng ~122). Đổi thànhWoolBall ball = await ....Thứ tự: gắn Hidden Yarn xong,
BuildTrainConnections()cuối cùng (dòng ~132) sẽ raiseOnBoardChanged⇒TryReveallần đầu chạy đúng (và bị loại khỏi train vì đã khoá). Ball đã register occupancy trongWoolBall.OnCreated.
4.1. ✅ Asset qua PrefabProfile + HiddenYarnVisual.cs (mới)
Sửa Data/PrefabProfile.cs — thêm (theo pattern WoolBallMaterial/WoolBallMesh):
public static Material HiddenYarnMaterial; // material xám neutral cho ball ẩn
public static Sprite QuestionMarkSprite; // dấu "?" (hoặc dùng TMP nếu chưa có sprite)[CHECK] cách
PrefabProfilenạp asset (Resources/serialized field/SO). Art cấpHiddenYarnMaterial(xám) +QuestionMarkSprite. Nếu sprite chưa có → tạm TMP"?", để TODO nối sprite sau.
Elements/Visual/HiddenYarnVisual.cs — khuôn WoolBallVisual (MonoBehaviour, Render(WoolBallData, Adapter)). Đặt trên child riêng "HiddenVisual".
Trách nhiệm:
- Mesh ball xám: tái dùng
PrefabProfile.WoolBallMesh, material =PrefabProfile.HiddenYarnMaterial. Render mọi piece theo footprint (tileId+childrenTileIds) — copy logic dựng piece +TileToLocalPositioncủaWoolBallVisual(không kế thừa, copy pattern). - Dấu
?: overlay world-space (QuestionMarkSpritetrên quad, hoặc TMP"?") gắn trên đỉnh ball (head piece). Multi-tile: 1 dấu?ở head là đủ (tunable). - API:
Render(data, adapter),SetActive(bool),UniTask PlayRevealOut(CancellationToken)(fade/scale dấu?+ ball xám),StopAllTweens().
4.2. Cấu trúc GameObject ball (sau khi gắn Hidden Yarn)
WoolBall (GameObject)
├─ [WoolBall] (domain, sẵn có)
├─ [HiddenYarn] (domain companion, MỚI — cùng GO)
├─ Visual (child sẵn có) → [WoolBallVisual] → Pieces (màu thật, bị ẩn khi hidden)
└─ HiddenVisual (child MỚI) → [HiddenYarnVisual] → Pieces xám + "?"
Switch trạng thái = bật/tắt 2 child + flag khoá (quyết định #3, #8). KHÔNG destroy để switch nhanh, giữ state.
- Vào hidden:
woolBall.Visual.SetAllPiecesVisible(false); - Khi reveal:
woolBall.Visual.SetAllPiecesVisible(true);
⚠️ SetAllPiecesVisible(false)cũng đượcWoolBall.Complete()dùng để ẩn ball hoàn tất. Hidden Yarn chỉ thao tác khi ball chưa Complete (ẩn→reveal xảy ra trước mọi dispatch hoàn tất) ⇒ không xung đột. Nếu muốn tách ngữ nghĩa, thêmSetColorVisualHidden(bool)riêng — [tunable §7].
KHÔNG tạo tool/tab mới. Dùng tool YarnBall sẵn có để tap chọn ball (_selectedYarnBall), bật/tắt trong inspector.
Khi _selectedYarnBall != null, thêm vào panel inspector ball:
- Toggle "Hidden Yarn" ↔
_selectedYarnBall.isHiddenYarn. - Khi tick ON: chạy validate (§5.2). Nếu ball không blocked-from-start → từ chối bật, cảnh báo inline ("Chỉ áp dụng cho ball bị chặn từ đầu"), revert toggle về OFF.
- Đổi giá trị hợp lệ →
MarkDirty()+ refresh preview (overlay?trên cell).
Rule cho mỗi ball có isHiddenYarn == true:
- Phải blocked-from-start tới Gate duy nhất: dựng board tĩnh ban đầu (active tiles + occupancy mọi ball/obstacle/tube authored) và kiểm tra KHÔNG có path từ ball tới
targetExitTileId. Có path →_validationErrors(block save) message rõ ràng.- [CHECK] editor có pathfinder không. Ưu tiên: (a) BFS tay trên
tileData+ tập occupied tiles (gọn, không phụ thuộc runtimeState); fallback (b) reuseBoardPathfinderchế độ editor. Vì chỉ 1 exit nên BFS tay rất nhẹ.
- [CHECK] editor có pathfinder không. Ưu tiên: (a) BFS tay trên
- Block nếu level KHÔNG có Gate (
!hasTargetExitTileId) mà vẫn có hidden ball → hidden ball không bao giờ lộ →_validationErrors(đổi từ warning thành error, vì single-exit là tiền đề bắt buộc). - Ball
isHiddenYarnvẫn phải hợp lệ như ball thường (rule sẵn có).
- Cell có hidden ball: vẽ overlay xám + ký hiệu
?đè lên swatch màu thật (author vẫn thấy màu nền: ví dụ viền màu thật + fill xám +?). - 3D scene preview (
ApplyFullLevelScenePreview): tùy chọn dựngHiddenYarnVisualpreview — phase sau, ưu tiên 2D overlay trước.
- Reveal một chiều: đã lộ → dù board đổi khiến lại bị chặn → KHÔNG ẩn lại.
IsRevealedchốt cứng; component đãenabled=false+ unsubscribe. - Ball multi-tile (
childrenTileIds): hidden visual phủ xám toàn thân +?ở head; điều kiện reveal dùngCanMoveToTarget()(đã xét cả root/tail endpoint) ⇒ nhất quán, không cần code riêng. - Level KHÔNG có Gate: hidden ball ở mãi trạng thái ẩn — editor đã chặn (5.2) nên runtime hiếm gặp; nếu lọt (JSON tay) thì ball ẩn vĩnh viễn, KHÔNG crash.
- Khoá tương tác khi ẩn (quyết định #8): ball ẩn KHÔNG được click/kéo train/merge; bị loại khỏi
RebuildTrainLinks. Khi reveal mới mở khoá. Đảm bảoSetInteractionLock(false)chạy đúng 1 lần lúc reveal. - Level unload giữa lúc reveal:
PlayRevealAsyncbọctry/catch OperationCanceledException;CleanupForLevelUnload/OnDestroyunsubscribe event + dừng tween, không để visual nửa vời. - Gắn nhầm cho ball thông sẵn (JSON tay vượt editor): runtime KHÔNG crash —
OnBoardChangedđầu tiên (sauBuildTrainConnectionslúc spawn) thấyCanMoveToTarget()==true→ lộ luôn. Editor mới là nơi chặn authoring. - Double-create:
HiddenYarnFactorydùngTryGetComponent→ không gắn 2 lần/GO. - Reentrancy event:
PlayRevealAsyncgọiBuildTrainConnections()→ raiseOnBoardChangedlại; vìIsRevealed==true,TryRevealreturn ngay (xem §3.4). Không vòng lặp. - Hiệu năng: event-driven, chỉ chạy
CanMoveToTarget()khi board đổi (không mỗi frame). Số hidden ball ít ⇒ rẻ.
| Q | Default | Ghi chú |
|---|---|---|
Reveal: disable hay Destroy HiddenYarn? |
Disable (enabled=false + unsubscribe) |
An toàn nếu còn reference; Destroy cũng được. |
| Reveal effect | Pop-scale + fade ? |
Tối thiểu là switch tức thì nếu gấp. |
SetColorVisualHidden(bool) riêng cho WoolBallVisual? |
Không — tái dùng SetAllPiecesVisible |
Thêm method riêng nếu muốn tách ngữ nghĩa khỏi Complete(). |
Dấu ? cho multi-tile |
1 dấu ở head | Có thể mỗi piece 1 dấu nếu art muốn. |
| Editor blocked-from-start: BFS tay hay reuse pathfinder | BFS tay trên tileData+occupancy (1 exit nên nhẹ) | Reuse BoardPathfinder nếu tách được. |
Sprite ? chưa có asset |
TMP "?" tạm |
Nối PrefabProfile.QuestionMarkSprite khi art cấp. |
Các điểm này nhỏ; nếu phát sinh lựa chọn lớn ngoài bảng, agent dùng AskUserQuestion trước khi code.
Gameplay
- Ball gắn Hidden Yarn hiển thị xám +
?lúc load, màu thật bị giấu. - Ball ẩn KHÔNG click/chọn/merge được; nhưng vẫn chặn đường/occupancy như ball thường (pathfinder không đi xuyên).
- Khi player clear vật cản mở đường ball→Gate (1 exit) → ball lộ màu thật + mở khoá ngay, đúng
ColorId. - Reveal một chiều: chặn lại sau đó không ẩn lại.
- Sau lộ: click/train/merge/dispatch hoạt động y như WoolBall thường.
- Nhiều hidden ball/level độc lập; ball nào thông tới Gate trước lộ trước.
- Ball multi-tile: phủ xám +
?; reveal đúng theo endpoint (CanMoveToTarget). - Re-eval chạy theo event (đặt breakpoint/log ở
OnBoardChanged), KHÔNG poll mỗi frame.
Editor
- Tap chọn ball → toggle Hidden Yarn bật/tắt; lưu vào
isHiddenYarn. - Bật toggle cho ball thông sẵn → bị từ chối + cảnh báo; chỉ ball blocked-from-start mới bật được.
- Validate chặn save khi: hidden ball không blocked-from-start, hoặc level thiếu Gate mà có hidden ball.
- Save → load round-trip giữ nguyên
isHiddenYarn. JSON cũ (không field) load rafalse, không lỗi. - Play Level từ editor preview dựng đúng hidden ball.
Verify cuối: mở 1 level test có Gate (1 exit) + vài hidden ball (1 single-tile, 1 multi-tile) bị chặn; play, clear dần để mở đường, quay clip kiểm 8 tiêu chí gameplay; round-trip save/load + diff JSON xác nhận isHiddenYarn.
Mới
Elements/Domain/HiddenYarn.cs(companion component, cùng GO với WoolBall, event-driven)Elements/Visual/HiddenYarnVisual.cs(visual xám +?)System/Creation/HiddenYarnFactory.cs(AddComponent, KHÔNG Instantiate)
Sửa
Data/WoolBallData.cs→ fieldbool isHiddenYarnData/PrefabProfile.cs→HiddenYarnMaterial+QuestionMarkSpriteSystem/Creation/YarnBoardCreationParameters.cs→HiddenYarnCreateParametersSystem/Creation/LevelSpawner.cs→ captureWoolBallreturn + gắnHiddenYarnFactorykhiisHiddenYarnSystem/Runtime/YarnBoardRuntimeState.cs→ eventOnBoardChanged(raise cuốiBuildTrainConnections+Unregister) + skip ballIsInteractionLockedtrong build train-linkElements/Domain/WoolBall.cs→IsInteractionLocked+SetInteractionLock(bool)+ guardOnInteract/CanBeClickedHeadEditor/C#/YarnBoardEditorWindow.Inspector.cs→ toggle Hidden Yarn cho ball đang chọnEditor/C#/YarnBoardEditorWindow.Validation.cs→ rule blocked-from-start (tới 1 exit) + bắt buộc có GateEditor/C#/YarnBoardEditorWindow.ScenePreview.cs/Preview.cs→ overlay xám +?
Không cần sửa (đã verify): Serialization.cs/Bobbins.cs (gán nguyên list yarnBalls = level.Balls, JsonUtility tự lo isHiddenYarn).
Thứ tự build đề xuất: Data (isHiddenYarn) → WoolBall lock API + RuntimeState event → Domain HiddenYarn + Factory + CreateParameters → Spawner wiring → test bằng JSON tay (1 ball blocked isHiddenYarn=true + 1 Gate) → Visual xám + ? (PrefabProfile asset) → Editor toggle/validation/preview.
| Yarn Tube | Hidden Yarn | |
|---|---|---|
| Loại entity | Entity độc lập, GO riêng | Modifier gắn lên WoolBall, cùng GO |
| Data | List yarnTubes trong LevelData |
Cờ isHiddenYarn trong WoolBallData (không sửa serialization) |
| Factory | Instantiate prefab mới | AddComponent vào GO ball có sẵn |
| Visual | 1 visual riêng | 2 visual toggle (default WoolBall + hidden xám/?) |
| Editor | Tool mới trong tab | Toggle trong inspector ball |
| Re-eval | Update poll (eject) | Event OnBoardChanged (không poll) |
| Trigger | Auto eject khi mouth trống | Reveal khi path tới Gate (1 exit) thông — một chiều |
| Tương tác | Ball đẩy ra là ball thường | Khoá click/train/merge tới khi lộ |