Pular para o conteúdo principal

Última Entrega — Guia Unity

Unity: setup e arquitetura​

O projeto usa Unity 6 com URP 2D, sistemas desacoplados por eventos em ScriptableObjects e dados de jogo (caixas, monstros, pedidos) em assets editáveis por designers sem tocar em código.

Criação do projeto

  1. Unity Hub → New Project → template Universal 2D (Unity 6 LTS).
  2. Package Manager: instalar Input System, Cinemachine, 2D Tilemap Extras, 2D Pixel Perfect (já no URP), Localization, Addressables e TextMeshPro.
  3. Project Settings → Player → Active Input Handling = Input System Package (New).
  4. Project Settings → Physics 2D: Gravity Y = -30 (pulo mais firme), Default Contact Offset = 0.005, e uma matriz de colisão com as camadas abaixo.
  5. Project Settings → Time: Fixed Timestep = 0.01 (100 Hz) para a física da pilha ficar estável.
  6. Câmera: Pixel Perfect Camera (Assets PPU = 32) + Cinemachine Camera com Follow e Confiner 2D por fase.

Camadas (Layers)

CamadaUso
GroundTilemaps e plataformas sólidas
PlayerEntregador
BoxCaixas da pilha e caixas soltas
MonsterCorpo dos monstros (sem colisão com o jogador)
OccluderCoberturas que bloqueiam linha de visão
InteractableClientes, terminais, pontos de troca

Matriz: Box colide com Ground e Box; Box não colide com Player (a pilha é presa por junta, não por contato); Monster não colide com nada além de Ground.

Estrutura de pastas

Assets/_Game/
Art/ Sprites, Tilesets, Animations, Materials
Audio/ Music, SFX, Mixer
Data/ Boxes, Monsters, Orders, Clients, Events
Prefabs/ Player, Boxes, Monsters, Interactables, UI
Scenes/ Boot, Depot, Level_01..04, Ending_Demo
Scripts/
Core/ GameEvents, GameState, SaveSystem, SceneLoader
Player/ CourierController, CourierInput, BalanceInput
Stack/ BoxStack, BoxItem, BoxData
Sanity/ SanitySystem, MonsterAura, SanityEffects
Monsters/ MonsterBrain, ObserverMonster, CrawlerMonster, ChoirMonster
Delivery/ OrderData, DeliveryManager, DeliveryPoint, BlackMarketPoint
Narrative/ ChoiceManager, DialogueRunner, WorldStateSwitcher
UI/ WristDevice, DeliveryJournal, PauseMenu

Arquitetura

  • Dados em ScriptableObjects: BoxData, MonsterData, OrderData, ClientData. Designers criam variações pelo menu Create.
  • Estado global: GameState (ScriptableObject) guarda créditos, loucura acumulada, lealdade, suspeita, flags narrativas. Persistido em JSON pelo SaveSystem.
  • Eventos: uma classe estática GameEvents com eventos C# (OnBoxLost, OnDeliveryCompleted, OnSanityCollapse, OnChoiceMade). Sistemas escutam em vez de se referenciar diretamente.
  • Cenas: Boot carrega o estado e o áudio persistente; Depot é o hub; cada fase é uma cena carregada pelo SceneLoader.
  • Loucura no mundo: WorldStateSwitcher lê a faixa de loucura acumulada e ativa variações de objetos, Volume de pós-processamento e snapshot de áudio.

Unity: scripts principais​

Os scripts abaixo formam a base jogável da demo: núcleo, entregador, pilha, loucura, monstros, entregas e fases. Todos usam as APIs do Unity 6 (linearVelocity, Input System, Cinemachine 3) e os valores numéricos são pontos de partida para ajuste no Play Mode.

Regras do projeto para estes scripts

  • Entregador e caixas com escala 1: o lado para onde o personagem olha é feito com SpriteRenderer.flipX, nunca com escala negativa, para não inverter as âncoras da pilha.
  • Caixas têm um filho Visual com o SpriteRenderer, usado pelo balanço visual falso da loucura.
  • Todo script que assina GameEvents cancela a assinatura em OnDisable.

Núcleo​

GameEvents.cs: barramento de eventos global, limpo ao entrar no Play Mode (funciona com Domain Reload desligado).

using System;
using UnityEngine;

public static class GameEvents
{
public static event Action<BoxItem> BoxLost;
public static event Action<BoxItem> BoxBroken;
public static event Action<Vector2, float> Noise; // posição, volume 0..1
public static event Action SanityCollapse;
public static event Action<OrderData, int> DeliveryCompleted;
public static event Action<string> ChoiceMade;

public static void RaiseBoxLost(BoxItem b) => BoxLost?.Invoke(b);
public static void RaiseBoxBroken(BoxItem b) => BoxBroken?.Invoke(b);
public static void RaiseNoise(Vector2 pos, float volume) => Noise?.Invoke(pos, volume);
public static void RaiseSanityCollapse() => SanityCollapse?.Invoke();
public static void RaiseDeliveryCompleted(OrderData o, int pay) => DeliveryCompleted?.Invoke(o, pay);
public static void RaiseChoiceMade(string id) => ChoiceMade?.Invoke(id);

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetAll()
{
BoxLost = null; BoxBroken = null; Noise = null;
SanityCollapse = null; DeliveryCompleted = null; ChoiceMade = null;
}
}

GameState.cs: estado da campanha num ScriptableObject (crie um asset em Data/GameState).

using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(menuName = "Ultima Entrega/Game State")]
public class GameState : ScriptableObject
{
public int credits;
[Range(0f, 100f)] public float accumulatedMadness;
public int loyalty; // > 0 bilionários, < 0 superfície
public int suspicion;
public int cloneNumber = 1;
public List<string> flags = new();
public List<string> barterItems = new();

public int MadnessBand =>
accumulatedMadness < 25f ? 0 :
accumulatedMadness < 50f ? 1 :
accumulatedMadness < 75f ? 2 : 3;

public bool HasFlag(string f) => flags.Contains(f);
public void SetFlag(string f) { if (!flags.Contains(f)) flags.Add(f); }

public void ResetForNewGame()
{
credits = 0; accumulatedMadness = 0f; loyalty = 0; suspicion = 0; cloneNumber = 1;
flags.Clear(); barterItems.Clear();
}
}

SaveSystem.cs e GameBootstrap.cs (este fica na cena Boot).

using System.IO;
using UnityEngine;
using UnityEngine.SceneManagement;

public static class SaveSystem
{
static string FilePath => Path.Combine(Application.persistentDataPath, "save.json");

public static void Save(GameState s) => File.WriteAllText(FilePath, JsonUtility.ToJson(s));

public static bool Load(GameState s)
{
if (!File.Exists(FilePath)) return false;
JsonUtility.FromJsonOverwrite(File.ReadAllText(FilePath), s);
return true;
}

public static void Delete() { if (File.Exists(FilePath)) File.Delete(FilePath); }
}

public class GameBootstrap : MonoBehaviour
{
[SerializeField] GameState state;
[SerializeField] string firstScene = "Depot";

void Start()
{
// O asset guarda valores do Play Mode anterior no editor: sempre resetar antes de carregar.
state.ResetForNewGame();
SaveSystem.Load(state);
SceneManager.LoadScene(firstScene);
}
}

IInteractable.cs

public interface IInteractable
{
void Interact(CourierController courier);
}

Quando um bloco trouxer mais de uma classe, separe cada MonoBehaviour e ScriptableObject num arquivo com o mesmo nome da classe; o Unity exige isso para anexar componentes e criar assets.

Caixas e pilha​

BoxData.cs: definição de cada tipo de caixa (crie um asset por tipo em Data/Boxes).

using UnityEngine;

public enum BoxKind { Common, Fragile, Heavy, Refrigerated, Sealed, Living }

[CreateAssetMenu(menuName = "Ultima Entrega/Box Data")]
public class BoxData : ScriptableObject
{
public string id;
public string displayName;
public BoxKind kind;
[Min(1)] public int weight = 1;
public int basePayout = 10;
public BoxItem prefab;

[Header("Frágil")] public float breakImpactSpeed = 6f;
[Header("Refrigerada")] public float timeLimit = 180f;
[Header("Viva")] public Vector2 wobbleInterval = new(2f, 5f);
public float wobbleKick = 3f; // rad/s por empurrão
}

BoxItem.cs: a caixa física. Fica solta no mundo, presa na pilha por uma HingeJoint2D ou quebrada.

using System.Collections;
using UnityEngine;

[RequireComponent(typeof(Rigidbody2D), typeof(BoxCollider2D))]
public class BoxItem : MonoBehaviour, IInteractable
{
public enum State { Loose, OnStack, Broken }
public static float FakeWobble; // graus, definido por SanityEffects

[SerializeField] BoxData data;
[SerializeField] Transform visual; // filho com o SpriteRenderer
[SerializeField] GameObject breakFx;

public BoxData Data => data;
public State Current { get; private set; } = State.Loose;
public float Condition { get; private set; } = 1f;
public bool SealBroken { get; private set; }
public float Age => Time.time - createdAt;
public Rigidbody2D Body { get; private set; }
public float HalfHeight { get; private set; }

HingeJoint2D joint;
float createdAt, wobbleSeed;

void Awake()
{
Body = GetComponent<Rigidbody2D>();
HalfHeight = GetComponent<BoxCollider2D>().size.y * 0.5f;
Body.mass = 0.2f * data.weight;
createdAt = Time.time;
wobbleSeed = Random.value * 10f;
}

public void AttachTo(Rigidbody2D below, Vector2 belowTopLocal, float limitDegrees)
{
joint = gameObject.AddComponent<HingeJoint2D>();
joint.autoConfigureConnectedAnchor = false;
joint.connectedBody = below;
joint.anchor = new Vector2(0f, -HalfHeight);
joint.connectedAnchor = belowTopLocal;
joint.enableCollision = false;
joint.useLimits = true;
joint.limits = new JointAngleLimits2D { min = -limitDegrees, max = limitDegrees };
Current = State.OnStack;
if (data.kind == BoxKind.Living) StartCoroutine(LivingWobble());
}

public void Detach()
{
if (joint) Destroy(joint);
joint = null;
if (Current == State.OnStack) Current = State.Loose;
}

public void Break()
{
if (Current == State.Broken) return;
Detach();
Current = State.Broken;
Condition = 0f;
GameEvents.RaiseBoxBroken(this);
GameEvents.RaiseNoise(transform.position, 1f);
if (breakFx) Instantiate(breakFx, transform.position, Quaternion.identity);
Destroy(gameObject);
}

public void OpenSeal() { if (data.kind == BoxKind.Sealed) SealBroken = true; }

public void Interact(CourierController courier)
{
if (Current == State.Loose) courier.Stack.TryAdd(this);
}

void OnCollisionEnter2D(Collision2D hit)
{
if (Current != State.Loose) return;
float speed = hit.relativeVelocity.magnitude;
if (speed < 2f) return;
GameEvents.RaiseNoise(transform.position, Mathf.Clamp01(speed / 10f));
if (data.kind == BoxKind.Fragile && speed > data.breakImpactSpeed) { Break(); return; }
Condition = Mathf.Max(0.25f, Condition - speed * 0.03f);
}

void LateUpdate()
{
if (!visual) return;
float angle = Current == State.OnStack ? Mathf.Sin(Time.time * 3f + wobbleSeed) * FakeWobble : 0f;
visual.localRotation = Quaternion.Euler(0f, 0f, angle);
}

IEnumerator LivingWobble()
{
while (Current == State.OnStack)
{
yield return new WaitForSeconds(Random.Range(data.wobbleInterval.x, data.wobbleInterval.y));
if (Current != State.OnStack) yield break;
float dir = Random.value < 0.5f ? -1f : 1f;
Body.AddTorque(dir * data.wobbleKick * Body.inertia, ForceMode2D.Impulse);
}
}
}

BoxStack.cs: fica no entregador. Prende as caixas em cadeia, aplica a força que as mantém de pé, o equilíbrio do jogador e quebra a pilha quando o ângulo passa do limite. A ordem importa: só a caixa do topo sai, então o jogador planeja a pilha pela ordem das entregas.

using System.Collections.Generic;
using UnityEngine;

public class BoxStack : MonoBehaviour
{
[SerializeField] Rigidbody2D carrier;
[SerializeField] Transform stackBase; // ponto vazio nas costas do entregador
[SerializeField] int maxBoxes = 6;
[Header("Quebra (graus)")]
[SerializeField] float baseBreakAngle = 35f, upperBreakAngle = 25f;
[Header("Equilíbrio (rad/s²)")]
[SerializeField] float uprightStiffness = 120f, uprightDamping = 10f, balanceStrength = 30f;
[SerializeField] float stabilizeDampingMultiplier = 5f, landingSpinPerSpeed = 0.15f;

readonly List<BoxItem> boxes = new();
float balanceInput;
bool stabilizing;

public int Count => boxes.Count;
public BoxItem Top => boxes.Count > 0 ? boxes[^1] : null;
public int TotalWeight { get { int w = 0; foreach (var b in boxes) w += b.Data.weight; return w; } }

public void SetBalanceInput(float value) => balanceInput = Mathf.Clamp(value, -1f, 1f);
public void SetStabilizing(bool value) => stabilizing = value;

public bool TryAdd(BoxItem box)
{
if (boxes.Count >= maxBoxes || box.Current != BoxItem.State.Loose) return false;

Rigidbody2D below;
Vector2 topLocal;
if (boxes.Count == 0)
{
below = carrier;
topLocal = carrier.transform.InverseTransformPoint(stackBase.position);
}
else
{
below = Top.Body;
topLocal = new Vector2(0f, Top.HalfHeight);
}

Vector2 baseWorld = below.transform.TransformPoint(topLocal);
Vector2 up = below.transform.up;
Vector2 pos = baseWorld + up * box.HalfHeight;
box.Body.position = pos;
box.Body.rotation = below.rotation;
box.transform.SetPositionAndRotation(pos, Quaternion.Euler(0f, 0f, below.rotation));
box.Body.linearVelocity = below.linearVelocity;
box.Body.angularVelocity = 0f;

float limit = (boxes.Count == 0 ? baseBreakAngle : upperBreakAngle) + 10f;
box.AttachTo(below, topLocal, limit);
boxes.Add(box);
return true;
}

public BoxItem TakeTop()
{
BoxItem box = Top;
if (box == null) return null;
boxes.RemoveAt(boxes.Count - 1);
box.Detach();
return box;
}

public List<BoxData> Snapshot()
{
var list = new List<BoxData>();
foreach (var b in boxes) list.Add(b.Data);
return list;
}

public void ClearAll()
{
foreach (var b in boxes) if (b) Destroy(b.gameObject);
boxes.Clear();
}

public void ApplyLandingImpact(float fallSpeed)
{
for (int i = 0; i < boxes.Count; i++)
{
BoxItem box = boxes[i];
if (box.Data.kind == BoxKind.Fragile && fallSpeed > box.Data.breakImpactSpeed)
{
DropFrom(i + 1);
boxes.RemoveAt(i);
box.Break();
return;
}
float spin = Random.Range(-1f, 1f) * fallSpeed * landingSpinPerSpeed;
box.Body.AddTorque(spin * box.Body.inertia, ForceMode2D.Impulse);
}
}

void DropFrom(int index)
{
if (index >= boxes.Count) return;
for (int j = boxes.Count - 1; j >= index; j--)
{
boxes[j].Detach();
GameEvents.RaiseBoxLost(boxes[j]);
}
boxes.RemoveRange(index, boxes.Count - index);
}

void FixedUpdate()
{
for (int i = 0; i < boxes.Count; i++)
{
BoxItem box = boxes[i];
Rigidbody2D below = i == 0 ? carrier : boxes[i - 1].Body;
float relAngle = Mathf.DeltaAngle(below.rotation, box.Body.rotation);
float limit = i == 0 ? baseBreakAngle : upperBreakAngle;
if (Mathf.Abs(relAngle) > limit) { DropFrom(i); return; }

float relVel = box.Body.angularVelocity - below.angularVelocity; // graus/s
float damping = uprightDamping * (stabilizing ? stabilizeDampingMultiplier : 1f);
float accel = -uprightStiffness * relAngle * Mathf.Deg2Rad
- damping * relVel * Mathf.Deg2Rad
- balanceInput * balanceStrength;
int boxesAbove = boxes.Count - 1 - i;
box.Body.AddTorque(accel * box.Body.inertia * (1 + boxesAbove));
}
}
}

Entregador​

CourierController.cs: movimento com peso, coyote time, jump buffer, pulo variável, estabilizar agachado e atraso de entrada vindo da loucura.

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;

[RequireComponent(typeof(Rigidbody2D))]
public class CourierController : MonoBehaviour
{
[Header("Input")]
[SerializeField] InputActionReference moveAction, jumpAction, runAction, balanceAction;
[Header("Referências")]
[SerializeField] BoxStack stack;
[SerializeField] SanitySystem sanity;
[SerializeField] SpriteRenderer sprite;
[SerializeField] Transform groundCheck;
[SerializeField] Vector2 groundCheckSize = new(0.6f, 0.1f);
[SerializeField] LayerMask groundMask;
[Header("Movimento")]
[SerializeField] float walkSpeed = 4.5f, runSpeed = 7f, acceleration = 40f, deceleration = 50f;
[SerializeField] float jumpHeight = 3.2f, coyoteTime = 0.1f, jumpBuffer = 0.1f, jumpCut = 0.5f;
[SerializeField] float speedLossPerWeight = 0.06f, jumpLossPerWeight = 0.05f, minFactor = 0.5f;
[SerializeField] float crouchStabilizeTime = 1f;

public BoxStack Stack => stack;
public Rigidbody2D Body { get; private set; }
public bool IsGrounded { get; private set; }

readonly Queue<(float time, Vector2 move)> moveHistory = new();
Vector2 delayedMove;
bool runHeld, jumpReleased;
float lastGroundedTime = -10f, lastJumpPressedTime = -10f, lastVerticalSpeed, crouchTimer;

void Awake()
{
Body = GetComponent<Rigidbody2D>();
Body.freezeRotation = true;
}

void OnEnable()
{
moveAction.action.Enable(); jumpAction.action.Enable();
runAction.action.Enable(); balanceAction.action.Enable();
jumpAction.action.performed += OnJumpPressed;
jumpAction.action.canceled += OnJumpReleased;
}

void OnDisable()
{
jumpAction.action.performed -= OnJumpPressed;
jumpAction.action.canceled -= OnJumpReleased;
}

void OnJumpPressed(InputAction.CallbackContext _) => lastJumpPressedTime = Time.time;
void OnJumpReleased(InputAction.CallbackContext _) => jumpReleased = true;

void Update()
{
// Atraso de entrada: com a loucura alta, o corpo responde um pouco depois.
Vector2 raw = moveAction.action.ReadValue<Vector2>();
moveHistory.Enqueue((Time.time, raw));
float delay = sanity ? sanity.InputDelay : 0f;
while (moveHistory.Count > 0 && moveHistory.Peek().time <= Time.time - delay)
delayedMove = moveHistory.Dequeue().move;

runHeld = runAction.action.IsPressed();
stack.SetBalanceInput(balanceAction.action.ReadValue<float>());
if (Mathf.Abs(delayedMove.x) > 0.1f) sprite.flipX = delayedMove.x < 0f;
}

void FixedUpdate()
{
bool wasGrounded = IsGrounded;
IsGrounded = Physics2D.OverlapBox(groundCheck.position, groundCheckSize, 0f, groundMask);
if (IsGrounded) lastGroundedTime = Time.time;
if (IsGrounded && !wasGrounded) stack.ApplyLandingImpact(-lastVerticalSpeed);

int weight = stack.TotalWeight;
float speedFactor = Mathf.Max(minFactor, 1f - weight * speedLossPerWeight);
float jumpFactor = Mathf.Max(minFactor, 1f - weight * jumpLossPerWeight);

Vector2 v = Body.linearVelocity;
float target = delayedMove.x * (runHeld ? runSpeed : walkSpeed) * speedFactor;
float rate = Mathf.Abs(target) > 0.01f ? acceleration : deceleration;
v.x = Mathf.MoveTowards(v.x, target, rate * Time.fixedDeltaTime);

bool canJump = Time.time - lastGroundedTime <= coyoteTime;
bool wantsJump = Time.time - lastJumpPressedTime <= jumpBuffer;
if (canJump && wantsJump)
{
float g = Mathf.Abs(Physics2D.gravity.y * Body.gravityScale);
v.y = Mathf.Sqrt(2f * g * jumpHeight * jumpFactor);
lastJumpPressedTime = -10f;
lastGroundedTime = -10f;
}
if (jumpReleased)
{
if (v.y > 0f) v.y *= jumpCut;
jumpReleased = false;
}

bool crouching = IsGrounded && delayedMove.y < -0.5f && Mathf.Abs(v.x) < 0.1f;
crouchTimer = crouching ? crouchTimer + Time.fixedDeltaTime : 0f;
stack.SetStabilizing(crouchTimer >= crouchStabilizeTime);

lastVerticalSpeed = v.y;
Body.linearVelocity = v;
}
}

CourierInteractor.cs: botão de interagir. Pega a caixa solta ou o ponto de interação mais próximo, ignorando as caixas que já estão na pilha.

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;

public class CourierInteractor : MonoBehaviour
{
[SerializeField] InputActionReference interactAction;
[SerializeField] CourierController courier;
[SerializeField] float radius = 1.2f;
[SerializeField] LayerMask interactMask; // Interactable + Box

readonly List<Collider2D> results = new();
ContactFilter2D filter;

void Awake()
{
filter = new ContactFilter2D { useTriggers = true };
filter.SetLayerMask(interactMask);
}

void OnEnable()
{
interactAction.action.Enable();
interactAction.action.performed += OnInteract;
}

void OnDisable() => interactAction.action.performed -= OnInteract;

void OnInteract(InputAction.CallbackContext _)
{
Physics2D.OverlapCircle(transform.position, radius, filter, results);
IInteractable best = null;
float bestDist = float.MaxValue;
foreach (var col in results)
{
if (col.TryGetComponent(out BoxItem box) && box.Current != BoxItem.State.Loose) continue;
if (!col.TryGetComponent(out IInteractable target)) continue;
float d = ((Vector2)col.transform.position - (Vector2)transform.position).sqrMagnitude;
if (d < bestDist) { bestDist = d; best = target; }
}
best?.Interact(courier);
}
}

Loucura​

SanitySystem.cs: fica no entregador. Soma a exposição de todas as auras no quadro, aplica o multiplicador da loucura acumulada e dispara o colapso. Roda depois das auras (DefaultExecutionOrder).

using UnityEngine;

[DefaultExecutionOrder(100)]
public class SanitySystem : MonoBehaviour
{
[SerializeField] GameState state;
[SerializeField] float decayDelay = 2f, decayRate = 8f;
[SerializeField, Range(0f, 1f)] float exposureToAccumulated = 0.05f;
[SerializeField] float inputDelayThreshold = 80f, inputDelaySeconds = 0.05f;

public float Acute { get; private set; }
public float InputDelay => Acute >= inputDelayThreshold ? inputDelaySeconds : 0f;

float pendingRate, sinceExposure, exposureThisLevel;
bool collapsed;

public void AddExposure(float perSecond) => pendingRate += perSecond;

void Update()
{
if (collapsed) { pendingRate = 0f; return; }

if (pendingRate > 0f)
{
float gain = pendingRate * (1f + state.accumulatedMadness / 100f) * Time.deltaTime;
Acute += gain;
exposureThisLevel += gain;
sinceExposure = 0f;
}
else
{
sinceExposure += Time.deltaTime;
if (sinceExposure >= decayDelay) Acute -= decayRate * Time.deltaTime;
}
pendingRate = 0f;
Acute = Mathf.Clamp(Acute, 0f, 100f);

if (Acute >= 100f)
{
collapsed = true;
GameEvents.RaiseSanityCollapse();
}
}

public void ResetAcute()
{
Acute = 0f;
sinceExposure = 0f;
collapsed = false;
}

// Chamado ao concluir a fase: progresso + parte da exposição viram cicatriz permanente.
public void CommitLevel(float completionGain)
{
state.accumulatedMadness = Mathf.Min(100f,
state.accumulatedMadness + completionGain + exposureThisLevel * exposureToAccumulated);
exposureThisLevel = 0f;
}
}

SanityEffects.cs: liga a loucura aguda a um Volume de pós-processamento (vinheta, aberração cromática, film grain), ao tremor da câmera, ao batimento cardíaco e ao balanço visual falso das caixas.

using UnityEngine;
using UnityEngine.Rendering;
using Unity.Cinemachine;

public class SanityEffects : MonoBehaviour
{
[SerializeField] SanitySystem sanity;
[SerializeField] Volume madnessVolume;
[SerializeField] CinemachineBasicMultiChannelPerlin cameraNoise;
[SerializeField] AudioSource heartbeat;
[SerializeField] float maxShake = 1.2f, maxFakeWobble = 6f;

void Update()
{
float a = sanity.Acute;
float t = Mathf.InverseLerp(40f, 100f, a);
madnessVolume.weight = t;
cameraNoise.AmplitudeGain = Mathf.Lerp(0f, maxShake, t);
heartbeat.volume = Mathf.InverseLerp(30f, 100f, a);
heartbeat.pitch = Mathf.Lerp(0.9f, 1.6f, a / 100f);
BoxItem.FakeWobble = Mathf.InverseLerp(60f, 100f, a) * maxFakeWobble;
}
}

MadnessGate.cs: liga ou desliga objetos do cenário conforme a faixa de loucura acumulada (plataformas falsas, grafites que mudam, cadáveres que aparecem). Coloque em qualquer objeto da fase.

using UnityEngine;

public class MadnessGate : MonoBehaviour
{
[SerializeField] GameState state;
[SerializeField, Range(0, 3)] int minBand = 2;
[SerializeField] bool hideFromBand; // true: some a partir da faixa

void Start()
{
bool reached = state.MadnessBand >= minBand;
gameObject.SetActive(hideFromBand ? !reached : reached);
}
}

Monstros​

MonsterAura.cs: todo monstro tem uma. Quanto mais perto, maior a exposição; linha de visão livre multiplica, cobertura reduz (ou zera, no Observador).

using UnityEngine;

public class MonsterAura : MonoBehaviour
{
[SerializeField] float radius = 6f, maxPerSecond = 25f;
[SerializeField] float lineOfSightMultiplier = 1.5f, occludedMultiplier = 0.35f;
[SerializeField] bool requiresLineOfSight; // Observador: true
[SerializeField] LayerMask occluderMask;

SanitySystem target;

void Start() => target = FindFirstObjectByType<SanitySystem>();

void Update()
{
if (!target) return;
Vector2 from = transform.position, to = target.transform.position;
float dist = Vector2.Distance(from, to);
if (dist > radius) return;

float rate = maxPerSecond * (1f - dist / radius);
bool blocked = Physics2D.Linecast(from, to, occluderMask);
if (blocked) rate *= requiresLineOfSight ? 0f : occludedMultiplier;
else rate *= lineOfSightMultiplier;

if (rate > 0f) target.AddExposure(rate);
}

void OnDrawGizmosSelected()
{
Gizmos.color = new Color(0.6f, 0f, 0.8f, 0.4f);
Gizmos.DrawWireSphere(transform.position, radius);
}
}

CrawlerMonster.cs (Rastejante): patrulha entre pontos com pausas, criando janelas de passagem.

using UnityEngine;

public class CrawlerMonster : MonoBehaviour
{
[SerializeField] Transform[] waypoints;
[SerializeField] float speed = 1.5f, waitTime = 2f;

int index;
float waitUntil;

void Update()
{
if (waypoints.Length == 0 || Time.time < waitUntil) return;
Vector3 goal = waypoints[index].position;
transform.position = Vector3.MoveTowards(transform.position, goal, speed * Time.deltaTime);
if ((transform.position - goal).sqrMagnitude < 0.01f)
{
index = (index + 1) % waypoints.Length;
waitUntil = Time.time + waitTime;
}
}
}

ChoirMonster.cs (Coro): cada criatura do grupo vai até o barulho mais recente e depois volta para casa. Várias auras pequenas somadas tornam o grupo perigoso.

using UnityEngine;

public class ChoirMonster : MonoBehaviour
{
[SerializeField] float hearingRange = 12f, speed = 2f, interestDuration = 6f;

Vector2 home, noiseTarget;
float interestUntil;

void Awake() => home = transform.position;
void OnEnable() => GameEvents.Noise += OnNoise;
void OnDisable() => GameEvents.Noise -= OnNoise;

void OnNoise(Vector2 pos, float volume)
{
if (Vector2.Distance(pos, transform.position) > hearingRange * volume) return;
noiseTarget = pos;
interestUntil = Time.time + interestDuration;
}

void Update()
{
Vector2 goal = Time.time < interestUntil ? noiseTarget : home;
transform.position = Vector2.MoveTowards(transform.position, goal, speed * Time.deltaTime);
}
}

O Observador não precisa de script próprio: é um sprite animado parado com MonsterAura (raio 10, requiresLineOfSight ligado).

Entregas e mercado paralelo​

ClientData.cs e OrderData.cs: clientes e pedidos como assets (Data/Clients, Data/Orders).

using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(menuName = "Ultima Entrega/Client")]
public class ClientData : ScriptableObject
{
public string displayName;
public string bunkerName;
[TextArea] public string[] linesLucid;
[TextArea] public string[] linesFractured; // usadas na faixa de loucura 2 ou mais
}

[CreateAssetMenu(menuName = "Ultima Entrega/Order")]
public class OrderData : ScriptableObject
{
public string id;
public ClientData client;
public string deliveryPointId;
public List<BoxData> boxes = new();
public float payoutMultiplier = 1f;
}

DeliveryManager.cs: um por fase. Controla o que falta entregar e calcula o pagamento (condição da caixa, tempo das refrigeradas, lacre violado).

using System.Collections.Generic;
using UnityEngine;

public class DeliveryManager : MonoBehaviour
{
public static DeliveryManager Instance { get; private set; }

[SerializeField] GameState state;
[SerializeField] List<OrderData> levelOrders = new();

readonly Dictionary<string, List<BoxData>> remaining = new();

void Awake()
{
Instance = this;
foreach (var o in levelOrders) remaining[o.id] = new List<BoxData>(o.boxes);
}

public bool AllDelivered => levelOrders.TrueForAll(o => remaining[o.id].Count == 0);

public int MissingCount
{
get { int n = 0; foreach (var o in levelOrders) n += remaining[o.id].Count; return n; }
}

public OrderData FindOrder(string pointId) =>
levelOrders.Find(o => o.deliveryPointId == pointId && remaining[o.id].Count > 0);

public int Deliver(OrderData order, BoxStack stack)
{
var list = remaining[order.id];
int payout = 0;
while (stack.Count > 0 && list.Contains(stack.Top.Data))
{
BoxItem box = stack.TakeTop();
list.Remove(box.Data);
payout += Mathf.RoundToInt(box.Data.basePayout * box.Condition * TimeFactor(box) * order.payoutMultiplier);
if (box.SealBroken) state.suspicion += 10;
Destroy(box.gameObject);
}
if (payout > 0)
{
state.credits += payout;
state.loyalty += 1;
GameEvents.RaiseDeliveryCompleted(order, payout);
}
return payout;
}

static float TimeFactor(BoxItem box)
{
if (box.Data.kind != BoxKind.Refrigerated) return 1f;
float late = Mathf.Max(0f, box.Age - box.Data.timeLimit);
return Mathf.Clamp(1f - late / box.Data.timeLimit, 0.25f, 1f);
}
}

DeliveryPoint.cs e BlackMarketPoint.cs: os dois lados da escolha. Ficam na camada Interactable com um collider em modo trigger.

using UnityEngine;

public class DeliveryPoint : MonoBehaviour, IInteractable
{
[SerializeField] string pointId;

public void Interact(CourierController courier)
{
OrderData order = DeliveryManager.Instance.FindOrder(pointId);
if (order == null) return; // UI: "nada para este bunker"
DeliveryManager.Instance.Deliver(order, courier.Stack);
}
}

public class BlackMarketPoint : MonoBehaviour, IInteractable
{
[SerializeField] GameState state;
[SerializeField] string[] barterRewards = { "calmante", "mapa_de_rota" };
[SerializeField] int loyaltyCost = 3, suspicionGain = 5;

public void Interact(CourierController courier)
{
BoxItem box = courier.Stack.TakeTop();
if (box == null) return;
state.loyalty -= loyaltyCost;
state.suspicion += suspicionGain;
state.barterItems.Add(barterRewards[Random.Range(0, barterRewards.Length)]);
state.SetFlag("mercado_paralelo_contato");
Destroy(box.gameObject);
}
}

Fases, morte e checkpoints​

LevelManager.cs: um por fase. No colapso mental, ativa o próximo clone no último checkpoint com a pilha que ele tinha ali. Ao terminar a fase, grava a loucura acumulada e salva.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

public class LevelManager : MonoBehaviour
{
[SerializeField] CourierController courier;
[SerializeField] SanitySystem sanity;
[SerializeField] GameState state;
[SerializeField] float levelCompletionMadness = 5f;
[SerializeField] int suspicionPerMissingBox = 5;
[SerializeField] float respawnDelay = 1.5f;

Vector2 checkpointPos;
List<BoxData> checkpointBoxes = new();

[SerializeField] List<BoxData> startingBoxes = new(); // carga da fase, de baixo para cima

void Start()
{
foreach (var data in startingBoxes)
courier.Stack.TryAdd(Instantiate(data.prefab, courier.transform.position, Quaternion.identity));
SetCheckpoint(courier.transform.position);
}
void OnEnable() => GameEvents.SanityCollapse += OnCollapse;
void OnDisable() => GameEvents.SanityCollapse -= OnCollapse;

public void SetCheckpoint(Vector2 pos)
{
checkpointPos = pos;
checkpointBoxes = courier.Stack.Snapshot();
}

void OnCollapse() => StartCoroutine(Respawn());

IEnumerator Respawn()
{
courier.enabled = false;
yield return new WaitForSeconds(respawnDelay); // trocar por fade + animação da cápsula

courier.Stack.ClearAll();
courier.Body.position = checkpointPos;
courier.Body.linearVelocity = Vector2.zero;
foreach (var data in checkpointBoxes)
{
BoxItem box = Instantiate(data.prefab, checkpointPos, Quaternion.identity);
courier.Stack.TryAdd(box);
}
sanity.ResetAcute();
state.cloneNumber++;
courier.enabled = true;
}

public void CompleteLevel(string nextScene)
{
state.suspicion += DeliveryManager.Instance.MissingCount * suspicionPerMissingBox;
sanity.CommitLevel(levelCompletionMadness);
SaveSystem.Save(state);
SceneManager.LoadScene(nextScene);
}
}

Checkpoint.cs e LevelExit.cs: triggers simples na fase.

using UnityEngine;

public class Checkpoint : MonoBehaviour
{
[SerializeField] LevelManager level;
bool used;

void OnTriggerEnter2D(Collider2D other)
{
if (used || !other.TryGetComponent(out CourierController _)) return;
used = true;
level.SetCheckpoint(transform.position);
}
}

public class LevelExit : MonoBehaviour
{
[SerializeField] LevelManager level;
[SerializeField] string nextScene = "Depot";

void OnTriggerEnter2D(Collider2D other)
{
if (other.TryGetComponent(out CourierController _)) level.CompleteLevel(nextScene);
}
}

Escolhas​

ChoiceData.cs e ChoiceManager.cs: cada escolha grande é um asset; a UI chama Apply com a opção escolhida.

using System;
using UnityEngine;

[CreateAssetMenu(menuName = "Ultima Entrega/Choice")]
public class ChoiceData : ScriptableObject
{
[Serializable]
public class Option
{
public string label;
public int loyaltyDelta;
public int suspicionDelta;
public string flag;
}

public string id;
[TextArea] public string prompt;
public Option[] options;
}

public class ChoiceManager : MonoBehaviour
{
[SerializeField] GameState state;

public void Apply(ChoiceData choice, int optionIndex)
{
ChoiceData.Option o = choice.options[optionIndex];
state.loyalty += o.loyaltyDelta;
state.suspicion += o.suspicionDelta;
if (!string.IsNullOrEmpty(o.flag)) state.SetFlag(o.flag);
state.SetFlag(choice.id);
GameEvents.RaiseChoiceMade(choice.id);
SaveSystem.Save(state);
}
}

Unity: montagem das cenas e roteiro da demo​

A demo é construída em seis etapas, cada uma terminando com algo jogável; a regra é não avançar enquanto o critério de saída da etapa não estiver cumprido.

Prefab do entregador (Prefabs/Player/Courier)

  1. Objeto raiz na camada Player: Rigidbody2D (Dynamic, Mass 10, Gravity Scale 1, Interpolate, Collision Detection Continuous), CapsuleCollider2D, CourierController, BoxStack, SanitySystem, CourierInteractor.
  2. Filhos: Sprite (SpriteRenderer + Animator), GroundCheck nos pés, StackBase nas costas, na altura dos ombros.
  3. Input Actions (Settings/CourierInput.inputactions): Move (Vector2), Jump, Run, Interact (Button) e Balance (Axis: Q/E e gatilhos LT/RT).
  4. Ligar as referências no Inspector e marcar groundMask = Ground.

Prefab de caixa (Prefabs/Boxes/Box_Common e variantes)

  1. Raiz na camada Box: Rigidbody2D (Dynamic, Interpolate), BoxCollider2D (1 × 0,8), BoxItem com o BoxData do tipo.
  2. Filho Visual com o SpriteRenderer, ligado ao campo visual.
  3. Um Physics Material 2D com atrito 0,6 e bounciness 0 para as caixas não deslizarem como gelo.

Cena de fase (Scenes/Level_XX)

  1. Grid com Tilemaps: Ground (TilemapCollider2D + CompositeCollider2D), Background, Foreground, e camadas de parallax.
  2. Câmera: Main Camera com Pixel Perfect Camera e CinemachineBrain; Cinemachine Camera seguindo o entregador, com Confiner 2D e o componente Basic Multi Channel Perlin (ligado ao SanityEffects).
  3. Global Volume normal + MadnessVolume (vinheta, aberração cromática, film grain, Color Adjustments dessaturado) com peso 0 controlado pelo SanityEffects.
  4. LevelManager e DeliveryManager num objeto _Level, com os pedidos e a carga inicial da fase.
  5. Monstros com MonsterAura, coberturas na camada Occluder, DeliveryPoint e BlackMarketPoint na camada Interactable, Checkpoint a cada 60 a 90 segundos de jogo e LevelExit no fim.
  6. Objetos com MadnessGate para as variações de mundo por faixa de loucura.

Roteiro de produção

  1. Sensação do movimento. Entregador e pilha numa sala de testes cinza. Saída: carregar 4 caixas e atravessar um percurso de pulos é difícil, mas justo, para 3 pessoas que testarem.
  2. Loucura e monstros. Auras, efeitos, colapso e respawn por clone, com os três monstros em salas de teste. Saída: dá para ler a presença do monstro só pelo som e pela tela.
  3. Entregas e economia. Pedidos, pontos de entrega, mercado paralelo, depósito como hub com loja e terminal, save. Saída: o loop completo funciona de ponta a ponta com arte provisória.
  4. Fases 1 a 4 em greybox. Layout de todas as fases com rotas alternativas. Saída: a demo inteira jogável do começo ao fim, entre 40 e 60 minutos.
  5. Arte, áudio e narrativa. Tilesets, sprites, animações, trilha, falas dos clientes, escolha da fase 4, gancho final e a cena Ending_Demo (lê loyalty e toca uma de duas sequências via Timeline). Saída: nenhum placeholder visível.
  6. Polimento e build. Localização (PT, EN, FR), acessibilidade, menus, ajustes de valores, performance e build para Steam. Saída: build estável enviada para playtest externo.

Riscos principais

  • A física da pilha é o maior risco: se parecer aleatória, o jogo frustra. Por isso a etapa 1 vem primeiro e só termina com playtest.
  • Efeitos de loucura podem cansar ou causar enjoo. As opções de acessibilidade entram desde a etapa 2, não no fim.