788 lines
38 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|