Ú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
- Unity Hub → New Project → template Universal 2D (Unity 6 LTS).
- Package Manager: instalar Input System, Cinemachine, 2D Tilemap Extras, 2D Pixel Perfect (já no URP), Localization, Addressables e TextMeshPro.
- Project Settings → Player → Active Input Handling = Input System Package (New).
- 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.
- Project Settings → Time: Fixed Timestep = 0.01 (100 Hz) para a física da pilha ficar estável.
- Câmera: Pixel Perfect Camera (Assets PPU = 32) + Cinemachine Camera com Follow e Confiner 2D por fase.
Camadas (Layers)
| Camada | Uso |
|---|---|
| Ground | Tilemaps e plataformas sólidas |
| Player | Entregador |
| Box | Caixas da pilha e caixas soltas |
| Monster | Corpo dos monstros (sem colisão com o jogador) |
| Occluder | Coberturas que bloqueiam linha de visão |
| Interactable | Clientes, 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 peloSaveSystem. - Eventos: uma classe estática
GameEventscom eventos C# (OnBoxLost,OnDeliveryCompleted,OnSanityCollapse,OnChoiceMade). Sistemas escutam em vez de se referenciar diretamente. - Cenas:
Bootcarrega o estado e o áudio persistente;Depoté o hub; cada fase é uma cena carregada peloSceneLoader. - Loucura no mundo:
WorldStateSwitcherlê 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
Visualcom o SpriteRenderer, usado pelo balanço visual falso da loucura. - Todo script que assina
GameEventscancela a assinatura emOnDisable.
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)
- Objeto raiz na camada
Player:Rigidbody2D(Dynamic, Mass 10, Gravity Scale 1, Interpolate, Collision Detection Continuous),CapsuleCollider2D,CourierController,BoxStack,SanitySystem,CourierInteractor. - Filhos:
Sprite(SpriteRenderer + Animator),GroundChecknos pés,StackBasenas costas, na altura dos ombros. - Input Actions (
Settings/CourierInput.inputactions): Move (Vector2), Jump, Run, Interact (Button) e Balance (Axis: Q/E e gatilhos LT/RT). - Ligar as referências no Inspector e marcar
groundMask= Ground.
Prefab de caixa (Prefabs/Boxes/Box_Common e variantes)
- Raiz na camada
Box:Rigidbody2D(Dynamic, Interpolate),BoxCollider2D(1 × 0,8),BoxItemcom oBoxDatado tipo. - Filho
Visualcom o SpriteRenderer, ligado ao campovisual. - 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)
- Grid com Tilemaps:
Ground(TilemapCollider2D + CompositeCollider2D),Background,Foreground, e camadas de parallax. - 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). - Global Volume normal +
MadnessVolume(vinheta, aberração cromática, film grain, Color Adjustments dessaturado) com peso 0 controlado peloSanityEffects. LevelManagereDeliveryManagernum objeto_Level, com os pedidos e a carga inicial da fase.- Monstros com
MonsterAura, coberturas na camadaOccluder,DeliveryPointeBlackMarketPointna camadaInteractable,Checkpointa cada 60 a 90 segundos de jogo eLevelExitno fim. - Objetos com
MadnessGatepara as variações de mundo por faixa de loucura.
Roteiro de produção
- 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.
- 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.
- 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.
- 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.
- Arte, áudio e narrativa. Tilesets, sprites, animações, trilha, falas dos clientes, escolha da fase 4, gancho final e a cena
Ending_Demo(lêloyaltye toca uma de duas sequências via Timeline). Saída: nenhum placeholder visível. - 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.