using System; using System.Collections; using System.Collections.Generic; using BlockOutReplica.Domain; using BlockOutReplica.Infrastructure; using BlockOutReplica.Presentation; using UnityEngine; namespace BlockOutReplica.Gameplay { public enum BlockOutScreenState { Playing, LevelComplete, Home, Failed } [DisallowMultipleComponent] public sealed partial class BlockOutGameController : MonoBehaviour { private readonly List pieces = new List(); private readonly List doors = new List(); private readonly List generated = new List(); private BlockOutProgress progress; private LevelDefinition level; private BlockOutMovementRules movementRules; private BlockPieceView draggingPiece; private Vector3 dragOffset; private Vector3 dragLeaderPosition; private Vector3 previousPointerBoardPosition; private bool hasPreviousPointerPosition; private bool timerRunning; private Camera gameCamera; private int activeExits; private Transform boardRoot; // Durations are provisional: original configuration values were not recovered. private const float SnapToDoorDuration = .10f; private const float MoveThroughDoorDuration = .36f; public float TimeLeft { get; private set; } public bool InputLocked { get; private set; } public bool IsPaused { get; private set; } public bool ShowTutorial => ScreenState == BlockOutScreenState.Playing && progress != null && progress.LevelNumber == 1 && !timerRunning && pieces.Count > 0; public bool IsDragging => draggingPiece != null; public bool IsInputReviewRunning { get; private set; } public string Status { get; private set; } public string CurrentLevelId => progress == null ? "t140-level-1" : progress.CurrentLevelId; public int LevelNumber => progress == null ? 1 : progress.LevelNumber; public int CompletedLevels => progress == null ? 0 : progress.CompletedLevels; public int ObjectiveProgress => Journey.NodeProgress(CompletedLevels); public int ObjectiveTarget => Journey.NodeTarget(CompletedLevels); public bool IsTutorialChapter => progress != null && progress.IsTutorialChapter; public BlockOutScreenState ScreenState { get; private set; } private void Awake() { ConfigureCamera(out gameCamera); gameObject.AddComponent().Initialize(this, gameCamera); gameObject.AddComponent().Initialize(this); progress = new BlockOutProgress(OriginalFlowRepository.Load()); InitializeInteractions(); ResetLevel(); if (progress.CatalogCompleted || progress.ShouldStartAtHome) GoHome(); } private void Update() { FitCameraToPortrait(gameCamera); if (Input.GetKeyDown(KeyCode.RightArrow)) LoadNextLevel(); if (Input.GetKeyDown(KeyCode.LeftArrow)) LoadPreviousLevel(); #if UNITY_EDITOR if (Input.GetKeyDown(KeyCode.F8) && ScreenState == BlockOutScreenState.Playing) StartCoroutine(ReviewDragFirstPiece()); if (Input.GetKeyDown(KeyCode.F9) && ScreenState == BlockOutScreenState.Playing) CompleteLevelForReview(); if (Input.GetKeyDown(KeyCode.F10)) ResetProgressForReview(); #endif if (Input.GetKeyDown(KeyCode.Escape)) { if (Modal != GameModal.None) CloseModal(); else if (SelectedBooster != BoosterType.None) CancelBoosterSelection(); else RequestPause(); } AdvanceClock(Time.deltaTime); } private void OnEnable() { #if UNITY_EDITOR // Rebuild transient rules after hot reload; board ownership is retained // by the named root, so the previous board is removed before rebuilding. if (Application.isPlaying && gameCamera != null && movementRules == null) { bool wasHome = ScreenState == BlockOutScreenState.Home; InitializeInteractions(); ResetLevel(); if (wasHome || progress.CatalogCompleted) GoHome(); } #endif } public void ResetLevel() { LoadCurrentLevel(); } public void LoadNextLevel() => LoadAdjacentLevel(1); public void LoadPreviousLevel() => LoadAdjacentLevel(-1); public void PlayFromHome() { RequestEnterLevel(); } public void ResetProgressForReview() { EnsureProgress(); progress.ResetCurrentRun(); LoadCurrentLevel(); } private void LoadAdjacentLevel(int direction) { EnsureProgress(); progress.MoveBy(direction); LoadCurrentLevel(); } private void LoadCurrentLevel(string reviewLevelId = null) { EnsureProgress(); StopAllCoroutines(); var pointer=GetComponent(); if(pointer!=null)pointer.Initialize(this,gameCamera); ClearGenerated(); pieces.Clear(); doors.Clear(); draggingPiece = null; timerRunning = false; InputLocked = false; activeExits = 0; IsInputReviewRunning = false; IsPaused = false; ResetRunInteractions(); ScreenState = BlockOutScreenState.Playing; Status = "拖动积木,从同色出口移出"; string loadedLevelId = reviewLevelId ?? CurrentLevelId; TimeLeft = OriginalLevelSettings.Find(loadedLevelId)?.levelDuration ?? 180; if (gameCamera != null && gameCamera.GetComponent() == null) gameCamera.gameObject.AddComponent(); level = OriginalLevelRepository.Load(loadedLevelId); movementRules = new BlockOutMovementRules(level, SpecialCanUseDoor); LevelViewFactory factory = new LevelViewFactory(GetBoardRoot(), Resources.Load("BlockColor"), generated); CreateBackgroundGlow(); CreateLight(); factory.BuildBoard(level); for (int i = 0; i < level.Doors.Count; i++) doors.Add(factory.CreateDoor(level, level.Doors[i], i)); for (int i = 0; i < level.Pieces.Count; i++) pieces.Add(factory.CreatePiece(level, level.Pieces[i], i)); SpecialPrepareLevel(); Debug.Log($"BLOCKOUT_LEVEL_READY: id={loadedLevelId}, pieces={pieces.Count}, doors={level.Doors.Count}, cells={level.Cells.Count}, walls={level.Walls.Count}"); } private void EnsureProgress() { if (progress == null) progress = new BlockOutProgress(OriginalFlowRepository.Load()); } public void BeginDrag(Vector2 screenPosition, Camera camera) { if (InputLocked || IsPaused || Modal != GameModal.None || ScreenState != BlockOutScreenState.Playing || TimeLeft <= 0f) return; // Keep the top controls and bottom booster tray from leaking clicks // through to world-space pieces behind the IMGUI layer. if (screenPosition.y > Screen.height * .82f || screenPosition.y < Screen.height * .13f) return; if (!TryScreenToBoard(screenPosition, camera, out Vector3 point)) return; BlockPieceView piece = FindSelectablePiece(screenPosition, point, camera); if (piece == null || !pieces.Contains(piece)) return; if (SelectedBooster != BoosterType.None) { ApplyBoosterTarget(piece); return; } if (!SpecialCanMove(piece)) return; draggingPiece = piece; timerRunning = true; Status = ""; dragOffset = piece.transform.position - point; dragLeaderPosition = piece.transform.position; previousPointerBoardPosition = point; hasPreviousPointerPosition = true; piece.SetDragging(true); SetDoorHighlight(piece.ColorId, true); } public void ContinueDrag(Vector2 screenPosition, Camera camera) { if (IsPaused || draggingPiece == null || !TryScreenToBoard(screenPosition, camera, out Vector3 point)) return; Vector3 current = draggingPiece.transform.position; Vector3 rawDesired = point + dragOffset; rawDesired.y = current.y; // The package config uses BlockSpeed=1000, so following is effectively // immediate; limiting the per-frame distance only prevents tunnelling. float dt = Mathf.Max(Time.deltaTime, .001f); dragLeaderPosition = Vector3.MoveTowards(dragLeaderPosition, rawDesired, 1000f * dt); Vector3 desired = dragLeaderPosition; Vector3 pointerDelta = hasPreviousPointerPosition ? point - previousPointerBoardPosition : Vector3.zero; previousPointerBoardPosition = point; hasPreviousPointerPosition = true; Vector3 resolved = ResolveDragPosition(current, desired, pointerDelta); DoorExitPlan plan = default; bool enteringDoor = false; // A fast swipe can cross the entire doorway in one input frame. // Commit at the first capture point so the exit animation still has a visible path. int captureSteps = Mathf.Max(1, Mathf.CeilToInt(Vector3.Distance(current, resolved) / .08f)); for (int step = 1; step <= captureSteps; step++) { Vector3 sample = Vector3.Lerp(current, resolved, step / (float)captureSteps); if (!movementRules.TryPlanDoorExit(draggingPiece, sample, pieces, pointerDelta, false, out plan)) continue; if (!movementRules.IsPathClear(draggingPiece, current, sample, pieces)) continue; resolved = sample; enteringDoor = true; break; } if (!enteringDoor && (resolved - desired).sqrMagnitude > .001f) draggingPiece.PlayBlocked(desired - resolved); Vector3 delta = resolved - draggingPiece.transform.position; draggingPiece.transform.position = resolved; draggingPiece.SetDragMotion(delta / Mathf.Max(Time.deltaTime, .016f)); if (enteringDoor) StartPieceExit(draggingPiece, plan); } private Vector3 ResolveDragPosition(Vector3 current, Vector3 desired, Vector3 rawDelta) { Vector3 direct = ResolveSegment(current, desired); if ((direct - desired).sqrMagnitude < .0001f) return direct; Vector3 xFirst = current; xFirst.x = ResolveAllowedAxis(xFirst, desired.x, true); xFirst.z = ResolveAllowedAxis(xFirst, desired.z, false); Vector3 zFirst = current; zFirst.z = ResolveAllowedAxis(zFirst, desired.z, false); zFirst.x = ResolveAllowedAxis(zFirst, desired.x, true); float xError = (xFirst - desired).sqrMagnitude; float zError = (zFirst - desired).sqrMagnitude; if (Mathf.Abs(xError - zError) > .0001f) return xError < zError ? xFirst : zFirst; return Mathf.Abs(rawDelta.x) >= Mathf.Abs(rawDelta.z) ? xFirst : zFirst; } private Vector3 ResolveSegment(Vector3 start, Vector3 target) { float distance = Vector3.Distance(start, target); int steps = Mathf.Clamp(Mathf.CeilToInt(distance / .12f), 1, 48); Vector3 allowed = start; for (int i = 1; i <= steps; i++) { Vector3 candidate = Vector3.Lerp(start, target, i / (float)steps); if (!movementRules.IsAllowed(draggingPiece, candidate, pieces)) break; allowed = candidate; } return allowed; } private float ResolveAllowedAxis(Vector3 current, float target, bool horizontal) { float allowed = horizontal ? current.x : current.z; int steps = Mathf.Max(1, Mathf.CeilToInt(Mathf.Abs(target - allowed) / .12f)); for (int step = 1; step <= steps; step++) { float next = Mathf.Lerp(horizontal ? current.x : current.z, target, step / (float)steps); Vector3 candidate = current; if (horizontal) candidate.x = next; else candidate.z = next; if (movementRules.IsAllowed(draggingPiece, candidate, pieces)) { allowed = next; continue; } float blocked = next; for (int i = 0; i < 10; i++) { float middle = (allowed + blocked) * .5f; candidate = current; if (horizontal) candidate.x = middle; else candidate.z = middle; if (movementRules.IsAllowed(draggingPiece, candidate, pieces)) allowed = middle; else blocked = middle; } break; } return allowed; } public void EndDrag() { if (draggingPiece == null) return; BlockPieceView piece = draggingPiece; draggingPiece = null; hasPreviousPointerPosition = false; piece.SetDragging(false); SetDoorHighlight(piece.ColorId, false); if (movementRules.TryPlanDoorExit(piece, piece.transform.position, pieces, Vector3.zero, true, out DoorExitPlan plan)) { StartPieceExit(piece, plan); return; } Vector3 sitTarget = movementRules.FindNearestLegalSit(piece, piece.transform.position, pieces); StartCoroutine(SitPiece(piece, sitTarget, .13f)); } private void StartPieceExit(BlockPieceView piece, DoorExitPlan plan) { if (piece == null || !pieces.Contains(piece)) return; if (draggingPiece == piece) draggingPiece = null; hasPreviousPointerPosition = false; piece.SetDragging(false); SetDoorHighlight(piece.ColorId, false); SpecialBeginRemoval(piece, false, plan.SnapPosition); pieces.Remove(piece); activeExits++; piece.BeginDoorExit(plan); StartCoroutine(ExitPiece(piece, plan)); } private IEnumerator SitPiece(BlockPieceView piece, Vector3 target, float duration) { InputLocked = true; Vector3 start = piece.transform.position; for (float elapsed = 0f; elapsed < duration; elapsed += Time.deltaTime) { float t = Mathf.Clamp01(elapsed / duration); float eased = 1f - Mathf.Pow(1f - t, 3f); piece.transform.position = Vector3.LerpUnclamped(start, target, eased); yield return null; } piece.transform.position = target; InputLocked = false; // A release can miss lateral capture, then settle exactly into the mouth. // Recheck the settled pose and its complete unobstructed exit path. if (movementRules.TryPlanDoorExit(piece, target, pieces, Vector3.zero, true, out DoorExitPlan plan)) StartPieceExit(piece, plan); else piece.PlaySnap(); } private BlockPieceView FindSelectablePiece(Vector2 screenPosition, Vector3 boardPoint, Camera camera) { Ray ray = camera.ScreenPointToRay(screenPosition); if (Physics.Raycast(ray, out RaycastHit hit, 100f)) { BlockPieceView direct = hit.collider.GetComponentInParent(); if (direct != null) return direct; } const float searchRadius = .8f; BlockPieceView nearest = null; float nearestDistance = searchRadius * searchRadius; for (int i = 0; i < pieces.Count; i++) { BlockPieceView candidate = pieces[i]; Collider collider = candidate.GetComponent(); if (collider == null) continue; Vector3 probe = new Vector3(boardPoint.x, candidate.transform.position.y, boardPoint.z); Vector3 closest = collider.ClosestPoint(probe); float distance = (new Vector2(closest.x, closest.z) - new Vector2(boardPoint.x, boardPoint.z)).sqrMagnitude; if (distance > nearestDistance) continue; nearestDistance = distance; nearest = candidate; } return nearest; } public void TogglePause() { RequestPause(); } private IEnumerator ExitPiece(BlockPieceView piece, DoorExitPlan plan) { Vector3 start = piece.transform.position; for (float elapsed = 0f; elapsed < SnapToDoorDuration; elapsed += Time.deltaTime) { if (IsPaused) { yield return null; elapsed -= Time.deltaTime; continue; } float t = Mathf.Clamp01(elapsed / SnapToDoorDuration); piece.transform.position = Vector3.Lerp(start, plan.SnapPosition, 1f - Mathf.Pow(1f - t, 3f)); yield return null; } piece.transform.position = plan.SnapPosition; DoorView door = plan.DoorIndex < doors.Count ? doors[plan.DoorIndex] : null; if (door != null) door.PlayAccept(plan,piece); for (float elapsed = 0f; elapsed < MoveThroughDoorDuration; elapsed += Time.deltaTime) { if (IsPaused) { yield return null; elapsed -= Time.deltaTime; continue; } float t = Mathf.Clamp01(elapsed / MoveThroughDoorDuration); float eased = t * t * (3f - 2f * t); piece.transform.position = Vector3.Lerp(plan.SnapPosition, plan.ClearPosition, eased); piece.SetExitProgress(t); yield return null; } piece.transform.position = plan.ClearPosition; SpecialCompleteRemoval(piece); Destroy(piece.gameObject); activeExits--; CloseUnusedDoors(); Status = pieces.Count == 0 ? "LEVEL COMPLETE!" : "出口正确"; yield return FinishBoardIfCleared(); } private void SpawnCompletionBurst() { for (int i = 0; i < 28; i++) { float angle = i * Mathf.PI * 2f / 28f + UnityEngine.Random.Range(-.12f, .12f); float speed = UnityEngine.Random.Range(2.4f, 4.8f); Vector3 velocity = new Vector3(Mathf.Cos(angle), UnityEngine.Random.Range(1.2f, 3.1f), Mathf.Sin(angle)) * speed; SpawnFx(Vector3.up * .45f, velocity, i % 3 == 0 ? 0 : (i % 3 == 1 ? 10 : 11), 1.15f); } } private void SetDoorHighlight(int colorId, bool value) { for (int i = 0; i < doors.Count; i++) if (doors[i] != null && doors[i].ColorId == colorId) doors[i].SetHighlighted(value); } private void SpawnFx(Vector3 position, Vector3 velocity, int colorId, float lifetime) { GameObject item = GameObject.CreatePrimitive(PrimitiveType.Cube); item.name = "Block Motion Spark"; item.transform.SetParent(GetBoardRoot()); item.transform.position = position; item.transform.localScale = new Vector3(.10f, .055f, .16f) * UnityEngine.Random.Range(.7f, 1.25f); Destroy(item.GetComponent()); Renderer renderer = item.GetComponent(); Material material = new Material(Shader.Find("Standard")); material.color = OriginalAssetCatalog.FallbackColor(colorId); if (material.HasProperty("_Glossiness")) material.SetFloat("_Glossiness", .82f); renderer.material = material; item.AddComponent().Initialize(velocity, lifetime, 4.8f); generated.Add(item); } private IEnumerator CompleteLevelFlow() { if (completionCommitted) yield break; completionCommitted = true; completionStarted = true; timerRunning = false; InputLocked = true; Modal = GameModal.None; SelectedBooster = BoosterType.None; SetPaused(false); ScreenState = BlockOutScreenState.LevelComplete; wonLevel = progress.LevelNumber; bool returnHome = progress.ShouldReturnHomeAfterWin; winReturnsHome = returnHome; bool advanced=progress.CompleteLevel(); // Preserve source Regular20 per win. Original early-wallet timing may // be deferred; an unchanged intermediate screenshot did not prove 0. awardedReward = advanced ? economy.RegularLevelWinCoinReward : 0; Player.AwardCoins(awardedReward); if (advanced) Journey.QueueWinReward(CompletedLevels); PlayUiSound("win_screen"); Debug.Log($"BLOCKOUT_LEVEL_COMPLETE: next={progress.LevelNumber}, returnHome={returnHome}"); yield return new WaitForSecondsRealtime(returnHome ? 1.4f : .85f); if (ScreenState == BlockOutScreenState.LevelComplete) ContinueAfterWin(); } #if UNITY_EDITOR public void ReviewExitEffects() { LoadCurrentLevel("t140-level-4"); IsInputReviewRunning = true; StartCoroutine(ReviewExitEffectsRoutine()); } private IEnumerator ReviewExitEffectsRoutine() { yield return null; System.IO.Directory.CreateDirectory("Docs/iterations"); // Clear the two real bottom blockers via their doors, not by deleting // obstacles or teleporting the long block through them. foreach (int color in new[] {11, 10}) { BlockPieceView blocker = pieces.Find(p => p.ColorId == color && p.InitialBoardPosition.z < -2.4f); BeginDrag(gameCamera.WorldToScreenPoint(blocker.transform.position), gameCamera); EndDrag(); yield return new WaitForSeconds(.6f); } BlockPieceView piece = pieces.Find(p => p.ColorId == 8); DoorView door = doors.Find(p => p.ColorId == 8); OriginalDoorExitEffect effect = door.RecoveredEffect; if (effect == null) { Debug.LogError("BLOCKOUT_EXIT_FX_RUNTIME: passed=False, recovered effect missing"); ResetLevel(); yield break; } Transform panel = effect.transform.Find("DoorViewRoot/DoorRoot"); Vector3 panelStart = panel.position; BeginDrag(gameCamera.WorldToScreenPoint(piece.transform.position), gameCamera); ContinueDrag(gameCamera.WorldToScreenPoint(piece.transform.position + Vector3.right * 2.3f), gameCamera); if (draggingPiece != null) EndDrag(); int peak = 0, capture = 0; float motion = 0f, maxRotation = 0f, elapsed = 0f; bool paused = true; float[] captureTimes = {.16f, .34f, .56f, .84f}; while (elapsed < 1.8f) { peak = Mathf.Max(peak, effect.LiveParticleCount); motion = Mathf.Max(motion, Vector3.Dot(panel.position - panelStart, Vector3.right)); if (piece != null) maxRotation = Mathf.Max(maxRotation, Quaternion.Angle(Quaternion.identity, piece.transform.Find("Animated Visual Root").localRotation)); if (capture < captureTimes.Length && elapsed >= captureTimes[capture]) { ScreenCapture.CaptureScreenshot($"Docs/iterations/2026-10-20-exit-fx-{capture}.png"); capture++; } if (elapsed > .27f && elapsed < .32f) { TogglePause(); float before = effect.PlaybackTime; int beforeCount = effect.LiveParticleCount; for (int frame = 0; frame < 8; frame++) yield return null; paused &= effect.PlaybackTime == before && effect.LiveParticleCount == beforeCount; TogglePause(); elapsed = .32f; } elapsed += Time.deltaTime; yield return null; } bool passed = pieces.Count == 15 && activeExits == 0 && piece == null && peak > 0 && motion > .15f && paused && maxRotation < .001f && !effect.IsPlaying && effect.LiveParticleCount == 0; string result = $"BLOCKOUT_EXIT_FX_RUNTIME: passed={passed}, remaining={pieces.Count}, peakParticles={peak}, outwardMotion={motion:F3}, pause={paused}, maxBlockRotation={maxRotation:F4}, tailCleared={!effect.IsPlaying && effect.LiveParticleCount == 0}"; if (passed) Debug.Log(result); else Debug.LogError(result); IsInputReviewRunning = false; ResetLevel(); } public void ReviewLevel4Regression() { LoadCurrentLevel("t140-level-4"); IsInputReviewRunning = true; StartCoroutine(ReviewLevel4RegressionRoutine(false, true, false)); } private IEnumerator ReviewLevel4RegressionRoutine(bool offsetTrial, bool visuals, bool aligned) { yield return null; foreach (BlockPieceView item in pieces) visuals &= item.GetComponentsInChildren().Length > 0 && OriginalAssetCatalog.LoadStud(item.ColorId) != null; System.IO.Directory.CreateDirectory("Docs/iterations"); if (!offsetTrial) ScreenCapture.CaptureScreenshot("Docs/iterations/2026-10-19-level4-colors.png"); yield return new WaitForEndOfFrame(); // Free the bottom-left neighbour by taking its actual black door. BlockPieceView black = pieces.Find(p => p.ColorId == 11 && p.InitialBoardPosition.z < -2.4f); BeginDrag(gameCamera.WorldToScreenPoint(black.transform.position), gameCamera); bool selectedBlack = draggingPiece == black; EndDrag(); yield return new WaitForSeconds(.65f); BlockPieceView white = pieces.Find(p => p.ColorId == 10 && p.InitialBoardPosition.z < -2.4f); BeginDrag(gameCamera.WorldToScreenPoint(white.transform.position), gameCamera); bool selectedWhite = draggingPiece == white; if (offsetTrial) white.transform.position += Vector3.left * .22f; bool legal = movementRules.IsAllowed(white, white.transform.position, pieces); EndDrag(); yield return new WaitForSeconds(.85f); bool exited = selectedBlack && selectedWhite && legal && black == null && white == null && pieces.Count == 16 && activeExits == 0 && !InputLocked; if (!offsetTrial) { // Level reload stops all coroutines: start the second pass explicitly. LoadCurrentLevel("t140-level-4"); IsInputReviewRunning = true; StartCoroutine(ReviewLevel4RegressionRoutine(true, visuals, exited)); yield break; } bool settled = exited; bool passed = visuals && aligned && settled; string result = $"BLOCKOUT_LEVEL4_REGRESSION: passed={passed}, originalSprites={visuals}, alignedRelease={aligned}, offsetReleaseSettlesAndExits={settled}, remaining={pieces.Count}"; if (passed) Debug.Log(result); else Debug.LogError(result); IsInputReviewRunning = false; ResetLevel(); } public void ReviewDoorExitFlow() { // An isolated first-level board; saved player progression is not changed. LoadCurrentLevel("t140-level-1"); IsInputReviewRunning = true; StartCoroutine(ReviewDoorExitFlowRoutine()); } private IEnumerator ReviewDoorExitFlowRoutine() { yield return null; BlockPieceView piece = pieces[0]; Vector3 start = piece.transform.position; Vector3 target = new Vector3(start.x, start.y, 1.55f); BeginDrag(gameCamera.WorldToScreenPoint(start), gameCamera); ContinueDrag(gameCamera.WorldToScreenPoint(target + Vector3.forward * 4f), gameCamera); yield return null; bool committed = activeExits == 1 && pieces.Count == 2 && draggingPiece == null; bool fastSwipeCaptured = piece != null && piece.transform.position.z < 1.65f; bool colliderDisabled = piece != null && !piece.GetComponent().enabled; bool materialApplied = piece != null && piece.GetComponentInChildren() .sharedMaterial.shader.name == "BlockOut/ClippedSprite"; bool canSelectOther = !InputLocked; if (committed) { BlockPieceView other = pieces[0]; BeginDrag(gameCamera.WorldToScreenPoint(other.transform.position), gameCamera); canSelectOther &= draggingPiece == other; if (draggingPiece != null) EndDrag(); } yield return new WaitForSeconds(.16f); System.IO.Directory.CreateDirectory("Docs/iterations"); ScreenCapture.CaptureScreenshot("Docs/iterations/2026-10-19-exit-midpoint.png"); float maximumRotation = 0f; for (int frames = 0; frames < 300 && piece != null; frames++) { Transform visual = piece.transform.Find("Animated Visual Root"); maximumRotation = Mathf.Max(maximumRotation, Quaternion.Angle(Quaternion.identity, visual.localRotation)); yield return null; } bool passed = committed && fastSwipeCaptured && piece == null && colliderDisabled && materialApplied && canSelectOther && pieces.Count == 2 && activeExits == 0 && maximumRotation < .001f; string result = $"BLOCKOUT_DOOR_RUNTIME_REVIEW: passed={passed}, committed={committed}, fastSwipeCaptured={fastSwipeCaptured}, colliderDisabled={colliderDisabled}, clipShader={materialApplied}, concurrentSelection={canSelectOther}, remaining={pieces.Count}, activeExits={activeExits}, maxRotation={maximumRotation:F4}"; if (passed) Debug.Log(result); else Debug.LogError(result); IsInputReviewRunning = false; ResetLevel(); } public void ReviewCollisionFeedback() { ResetLevel(); IsInputReviewRunning = true; StartCoroutine(ReviewCollisionFeedbackRoutine()); } private IEnumerator ReviewCollisionFeedbackRoutine() { yield return null; if (pieces.Count < 2 || InputLocked || IsDragging) { IsInputReviewRunning = false; yield break; } BlockPieceView piece = pieces[0]; Vector3 start = piece.transform.position; Vector3 pointerTarget = pieces[1].transform.position; BeginDrag(gameCamera.WorldToScreenPoint(start), gameCamera); if (draggingPiece != piece) { IsInputReviewRunning = false; Debug.LogError("BLOCKOUT_COLLISION_REVIEW: selection failed"); yield break; } float maxRotation = 0f; bool valid = true; int frames = 0; for (; frames < 180 && draggingPiece == piece; frames++) { ContinueDrag(gameCamera.WorldToScreenPoint(pointerTarget), gameCamera); yield return new WaitForEndOfFrame(); Transform visual = piece.transform.Find("Animated Visual Root"); maxRotation = Mathf.Max(maxRotation, Quaternion.Angle(Quaternion.identity, visual.localRotation)); valid &= movementRules.IsAllowed(piece, piece.transform.position, pieces); } Vector3 collisionPosition = piece.transform.position; EndDrag(); yield return new WaitForSeconds(.2f); bool passed = valid && maxRotation < .001f && frames == 180 && (collisionPosition - pointerTarget).sqrMagnitude > .01f; string message = $"BLOCKOUT_COLLISION_REVIEW: passed={passed}, frames={frames}, maxRotation={maxRotation:F4}, legal={valid}, start={start}, contact={collisionPosition}, sit={piece.transform.position}"; if (passed) Debug.Log(message); else Debug.LogError(message); IsInputReviewRunning = false; } private IEnumerator ReviewDragFirstPiece() { if (pieces.Count == 0 || gameCamera == null) yield break; BlockPieceView piece = pieces[0]; DoorDefinition door = null; for (int i = 0; i < level.Doors.Count; i++) if (level.Doors[i].ColorId == piece.ColorId) { door = level.Doors[i]; break; } if (door == null) yield break; Vector3 startWorld = piece.transform.position; Vector3 targetWorld = level.AverageWorld(door.Cells, .28f) + BlockOutMovementRules.DirectionFor(door.Side) * 2.2f; BeginDrag(gameCamera.WorldToScreenPoint(startWorld), gameCamera); for (int step = 1; step <= 36 && draggingPiece != null; step++) { Vector3 world = Vector3.Lerp(startWorld, targetWorld, step / 36f); ContinueDrag(gameCamera.WorldToScreenPoint(world), gameCamera); yield return null; } EndDrag(); } private void CompleteLevelForReview() { StopAllCoroutines(); for (int i = 0; i < pieces.Count; i++) if (pieces[i] != null) Destroy(pieces[i].gameObject); pieces.Clear(); Status = "LEVEL COMPLETE!"; StartCoroutine(CompleteLevelFlow()); } #endif private static bool TryScreenToBoard(Vector2 screenPosition, Camera camera, out Vector3 point) { Ray ray = camera.ScreenPointToRay(screenPosition); if (new Plane(Vector3.up, new Vector3(0f, .28f, 0f)).Raycast(ray, out float distance)) { point = ray.GetPoint(distance); return true; } point = default; return false; } private static void ConfigureCamera(out Camera camera) { camera = Camera.main; if (camera == null) { GameObject cameraObject = new GameObject("Main Camera"); cameraObject.tag = "MainCamera"; camera = cameraObject.AddComponent(); } camera.orthographic = true; camera.transform.position = new Vector3(0f, 10f, .15f); camera.transform.rotation = Quaternion.Euler(90f, 0f, 0f); camera.backgroundColor = new Color(.055f, .05f, .20f); camera.clearFlags = CameraClearFlags.SolidColor; FitCameraToPortrait(camera); } private static void FitCameraToPortrait(Camera camera) { if (camera == null) return; // Level 1 reference: the outer board occupies about 71% of the // 882-pixel game width. A 4.9-unit horizontal half-view reproduces it. camera.orthographicSize = Mathf.Max(5.8f, 4.45f / Mathf.Max(.01f, camera.aspect)); } private void CreateLight() { GameObject item = new GameObject("Soft Light"); item.transform.SetParent(GetBoardRoot()); Light light = item.AddComponent(); light.type = LightType.Directional; light.intensity = .92f; light.shadows = LightShadows.Soft; item.transform.rotation = Quaternion.Euler(45f, -30f, 0f); generated.Add(item); } private void CreateBackgroundGlow() { const int width = 64; const int height = 128; Texture2D texture = new Texture2D(width, height, TextureFormat.RGBA32, false); texture.name = "Runtime Background Glow"; texture.wrapMode = TextureWrapMode.Clamp; texture.filterMode = FilterMode.Bilinear; Color edge = new Color(.095f, .085f, .205f, 1f); Color center = new Color(.19f, .165f, .39f, 1f); for (int y = 0; y < height; y++) for (int x = 0; x < width; x++) { float nx = (x / (width - 1f) - .5f) * 1.55f; float ny = (y / (height - 1f) - .52f) * .95f; float glow = Mathf.Clamp01(1f - Mathf.Sqrt(nx * nx + ny * ny)); glow = Mathf.SmoothStep(0f, 1f, glow) * .86f; texture.SetPixel(x, y, Color.Lerp(edge, center, glow)); } texture.Apply(); Sprite sprite = Sprite.Create(texture, new Rect(0, 0, width, height), new Vector2(.5f, .5f), 32f); GameObject item = new GameObject("Background Glow"); item.transform.SetParent(GetBoardRoot()); item.transform.position = new Vector3(0f, -.72f, 0f); item.transform.rotation = Quaternion.Euler(90f, 0f, 0f); item.transform.localScale = new Vector3(12f / sprite.bounds.size.x, 21f / sprite.bounds.size.y, 1f); SpriteRenderer renderer = item.AddComponent(); renderer.sprite = sprite; renderer.sortingOrder = -100; generated.Add(item); } private void ClearGenerated() { // A named ownership root survives script hot reload, unlike readonly // runtime lists. Deactivate immediately so old colliders cannot overlap // the board loaded in this same frame. var roots = new List(); foreach (Transform child in transform) if (child.name == "Generated Board") roots.Add(child.gameObject); foreach (GameObject item in roots) { item.SetActive(false); Destroy(item); } boardRoot = null; for (int i = 0; i < generated.Count; i++) if (generated[i] != null) Destroy(generated[i]); generated.Clear(); } private Transform GetBoardRoot() { if (boardRoot == null) { boardRoot = new GameObject("Generated Board").transform; boardRoot.SetParent(transform, false); } return boardRoot; } } }