Files
2026-10-20 13:21:04 +08:00

788 lines
38 KiB
C#

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<BlockPieceView> pieces = new List<BlockPieceView>();
private readonly List<DoorView> doors = new List<DoorView>();
private readonly List<GameObject> generated = new List<GameObject>();
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<BlockOutInputController>().Initialize(this, gameCamera);
gameObject.AddComponent<BlockOutHud>().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<BlockOutInputController>();
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<AudioListener>() == null)
gameCamera.gameObject.AddComponent<AudioListener>();
level = OriginalLevelRepository.Load(loadedLevelId);
movementRules = new BlockOutMovementRules(level, SpecialCanUseDoor);
LevelViewFactory factory = new LevelViewFactory(GetBoardRoot(),
Resources.Load<Material>("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<BlockPieceView>();
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<Collider>();
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<Collider>());
Renderer renderer = item.GetComponent<Renderer>();
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<BlockFxParticle>().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<SpriteRenderer>().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<Collider>().enabled;
bool materialApplied = piece != null && piece.GetComponentInChildren<SpriteRenderer>()
.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>();
}
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>(); 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<SpriteRenderer>();
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<GameObject>();
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;
}
}
}