Genere: Horror Narrativo
Engine: Unity 2021 LTS+
Narrative Engine: Ink (Inkle)
Dead Air — A project by Michele Grimaldi | E-C-H-O SYSTEMS
- DEAD_AIR_STORY_ARCHITECTURE
- DEAD_AIR_CONCEPT
- DEAD AIR - Scena 1
- DEAD AIR - Funzioni e Ruolo Observer Pattern
- DEAD AIR - Main Menu State Pattern
- DEAD_AIR's Feedback
- DEAD AIR - Light GDD
- Cosa Fa Il Gioco
- Come Funziona Il Codice
- Struttura File
- Event Channels
- Come Scrivere Una Storia
- Come Aggiungere Un Nuovo Tag
- Schema Visivo Sistema
- Sistemi e Ruoli
- Troubleshooting
- Riferimenti Rapidi
- Prossimi Miglioramenti
- Contatti
- Audio Optimization
Sei un operatore 911 negli anni '90. Rispondi a chiamate di emergenza che diventano sempre più inquietanti.
Gameplay:
- Scegli una chiamata dal menu
- Ascolti e leggi il dialogo
- Scegli come rispondere
- La storia prosegue in base alle tue scelte
- Nota: Unity ottimizza automaticamente gli audio in base alla cartella (vedi Sezione 13).
Il gioco usa un sistema tag-driven: scrivi la storia in file .ink, aggiungi tag speciali, e il gioco reagisce automaticamente.
FILE INK (storia + tag)
↓
PARSER (legge i tag)
↓
CHANNELS (comunicazione tra sistemi)
↓
MANAGER (audio, UI, voice)
↓
EFFETTO NEL GIOCO
Esempio pratico:
911, what's your emergency? # speaker:ward # voice:ward_01
Il testo appare sullo schermo + parte l'audio della voce| Tag | Cosa Fa | Esempio |
|---|---|---|
#speaker:{nome} |
Cambia colore del testo | #speaker:iris |
#voice:{file} |
Riproduce voce personaggio | #voice:iris_01 |
#sfx:{file} |
Effetto sonoro | #sfx:phone_ring |
#amb:{file} |
Musica ambiente (loop) | #amb:dispatch_night |
#amb:stop |
Ferma musica ambiente | #amb:stop |
#ui:{comando} |
Comando speciale UI | #ui:dead_air_screen |
Nota (da Marzo 2026): I tag #speaker e #ui usano internamente enum type-safe (SpeakerType, UICommandType) per prevenire errori e migliorare manutenibilità. Vedi Sezione 6.5 per dettagli.
Assets/
├── Scripts/
│ ├── Narrative/
│ │ ├── StoryManager.cs → Carica Ink e coordina tutto
│ │ └── DialogueParser.cs → Legge i tag dal file Ink
│ │
│ ├── UI/
│ │ ├── DialogueUI.cs → Mostra testo e scelte
│ │ └── ChoiceButton.cs → Bottone per le scelte
│ │
│ ├── Audio/
│ │ ├── AudioManager.cs → SFX e Ambience
│ │ └── VoiceManager.cs → Voci dei personaggi
│ │
│ └── Events/
│ ├── Channels/ → Tipi di comunicazione
│ │ ├── StringEventChannel.cs
│ │ ├── VoidEventChannel.cs
│ │ └── ... (altri)
│ │
│ └── ScriptableObjects/ → Canali di comunicazione (14 file .asset)
│ ├── DialogueLineChannel.asset
│ ├── SFXRequestedChannel.asset
│ └── ... (altri)
│
├── Ink/
│ └── dead_air_demo_en.ink → Storia principale
│
├── Audio
Una Audio Library è un ScriptableObject che contiene una collezione di file audio con ID associati. Permette di:
- Condividere clip audio tra scene diverse
- Organizzare audio per categoria (SFX, Ambience, Voice)
- Cambiare audio senza modificare codice
Esempio: Voice_Demo_Iris.asset contiene tutti i 10 clip vocali di Iris (iris_01 → iris_10).
FILE INK: #voice:iris_01
↓
StoryManager legge tag
↓
Pubblica evento VoiceRequestedChannel("iris_01")
↓
VoiceManager riceve evento
↓
VoiceManager cerca "iris_01" in Voice_Demo_Iris.asset
↓
Riproduce iris_01.wav
| Library Type | Scopo | Esempio |
|---|---|---|
| SFX Library | Effetti sonori brevi | SFX_Demo.asset → phone_ring, glass_break |
| Ambience Library | Loop ambiente | Ambience_Demo.asset → dispatch_night |
| Voice Library | Voci personaggio | Voice_Demo_Iris.asset → iris_01...iris_10 |
STEP 1 — Crea Library:
Assets/Audio/Libraries/ → Right Click
→ Create → DEAD AIR → Audio → Audio Clip Library
→ Rinomina: "Voice_MyStory"
STEP 2 — Popola Library:
Voice_MyStory.asset Inspector:
├─ Library Name: "My Story Voice"
├─ Description: "Voci personaggi storia X"
└─ Clips (Array):
├─ [0] id: "character_01", clip: character_01.wav
├─ [1] id: "character_02", clip: character_02.wav
└─ ...
STEP 3 — Assegna a Manager:
Scene → VoiceManager Inspector
→ Voice Libraries (Array)
→ Drag "Voice_MyStory.asset"
STEP 4 — Usa in Ink:
Hello there! # voice:character_01Zero modifiche al codice C#.
| Approccio | Setup Nuova Storia | Riutilizzo Cross-Scene | Manutenibilità |
|---|---|---|---|
| Array Inspector (vecchio) | 15 min (riassegna tutto) | ❌ No (duplicazione) | ❌ Difficile |
| SO Libraries (attuale) | 2 min (drag & drop) | ✅ Sì (shared) | ✅ Facile |
È un "ponte di comunicazione" tra sistemi diversi. Invece di far parlare i sistemi direttamente, usiamo questi ponti.
Vantaggi:
- I sistemi non si conoscono tra loro (puoi modificare uno senza rompere gli altri)
- Puoi testare ogni sistema in isolamento
- Nessun memory leak
- Facile da debuggare dall'Inspector Unity
StoryManager legge il tag #sfx:phone_ring
↓
StoryManager pubblica l'evento sul canale "SFXRequestedChannel"
↓
AudioManager è in ascolto su quel canale
↓
AudioManager riceve "phone_ring" e riproduce il suono
Dialogo:
DialogueLineChannel→ Testo da mostrareSpeakerLineChannel→ Chi sta parlando + testoChoicesPresentedChannel→ Lista di scelte disponibili
Audio:
SFXRequestedChannel→ Effetto sonoro da riprodurreAmbienceStartChannel→ Ambiente da far partireAmbienceStopChannel→ Ferma ambienteVoiceRequestedChannel→ Voce da riprodurreVoiceStopChannel→ Ferma voce
Input Giocatore:
ContinueRequestedChannel→ Giocatore clicca per continuareChoiceSelectedChannel→ Giocatore sceglie un'opzione
Altri:
UICommandChannel→ Comandi speciali UIStoryEndChannel→ Storia terminataVoiceStartedChannel→ Voce iniziata (con durata)VoiceFinishedChannel→ Voce finita
Località: Assets/Scripts/Events/ScriptableObjects/
// IRIS CALL - The Bear
-> intro
=== intro ===
# amb:dispatch_night
# sfx:phone_ring
2 AM. Line 3 lights up.
+ [ANSWER]
-> answer
=== answer ===
# sfx:phone_pickup
911, what's the address of your emergency? # speaker:ward
Hi... I need help with the Bear. # speaker:iris # voice:iris_01
+ [What's your name?]
-> ask_name
+ [Where are you calling from?]
-> ask_location
=== ask_name ===
My name is Iris. # speaker:iris # voice:iris_02
-> END
=== ask_location ===
I'm... I'm at home. # speaker:iris # voice:iris_03
-> END| Tipo | Formato | Ottimizzazione Unity | Esempio |
|---|---|---|---|
| Voice | {speaker}_{numero}.wav |
ADPCM, Mono, Optimize SR | iris_01.wav |
| SFX | {descrizione}.wav |
ADPCM, Mono, Optimize SR | phone_ring.wav |
| Ambience | {luogo}.ogg |
Vorbis 70%, Streaming, Stereo | dispatch_night.ogg |
| Music | {mood}.ogg |
Vorbis 80%, Streaming, Stereo | tension_loop.ogg |
Nota: Unity ottimizza automaticamente gli audio in base alla cartella (vedi Sezione 13 - Audio Optimization).
Unity → Project → Assets/Scripts/Events/ScriptableObjects
→ Right Click → Create → DEAD AIR → Events → String Event Channel
→ Rinomina: "MusicRequestedChannel"
Aggiungi in alto (dopo linea 25):
private const string TAG_MUSIC = "music:";Aggiungi nella struct ParsedLine (dopo linea 60):
public string Music;
public bool HasMusic;Aggiungi nel metodo ParseTags() (dopo linea 100):
else if (trimmedTag.StartsWith(TAG_MUSIC))
{
result.Music = ExtractValue(trimmedTag, TAG_MUSIC);
result.HasMusic = !string.IsNullOrEmpty(result.Music);
}Aggiungi campo (dopo linea 50):
[SerializeField] private StringEventChannel musicRequestedChannel;Aggiungi nel metodo ProcessLine() (dopo linea 180):
if (parsed.HasMusic && musicRequestedChannel != null)
{
musicRequestedChannel.RaiseEvent(parsed.Music);
}using UnityEngine;
using DeadAir.Events;
public class MusicManager : MonoBehaviour
{
[SerializeField] private AudioSource musicSource;
[SerializeField] private StringEventChannel musicRequestedChannel;
private void OnEnable()
{
if (musicRequestedChannel != null)
musicRequestedChannel.Subscribe(PlayMusic);
}
private void OnDisable()
{
if (musicRequestedChannel != null)
musicRequestedChannel.Unsubscribe(PlayMusic);
}
private void PlayMusic(string musicId)
{
// Carica e riproduci la musica
Debug.Log($"Playing music: {musicId}");
}
}-
Hierarchy → Create Empty → "MusicManager"
-
Add Component → MusicManager
-
Inspector:
- Assegna AudioSource
- Drag "MusicRequestedChannel" nel campo
-
StoryManager Inspector:
- Drag "MusicRequestedChannel" nel campo
=== tense_moment ===
# music:tension_loop
Ward feels something is wrong.Fatto! Tempo stimato: 30 minuti.
I comandi UI (#ui:{comando}) sono istruzioni speciali nel file Ink che attivano comportamenti UI come:
- Mostrare schermate speciali (
#ui:dead_air_screen) - Tornare al menu (
#ui:return_to_menu) - Mostrare overlay, transizioni, o altri effetti UI custom
Da Marzo 2026, i comandi UI usano enum type-safe invece di stringhe fragili. Questo previene typo e rende il codice più manutenibile.
FILE INK: #ui:dead_air_screen
↓
DialogueParser.cs: "dead_air_screen" (string) → UICommandType.DeadAirScreen (enum)
↓
StoryManager.cs: UICommandType.DeadAirScreen → "dead_air_screen" (string per canale)
↓
UICommandChannel: Pubblica "dead_air_screen"
↓
DialogueUI.cs: "dead_air_screen" (string) → UICommandType.DeadAirScreen (enum)
↓
DialogueUI.cs: Switch su enum → ShowDeadAirScreen()
Perché questo flusso?
- Event Channels usano ancora
stringper backward compatibility - Parser e UI convertono in enum per type safety e exhaustiveness check
- Un typo nel file Ink genera warning a runtime (es. "Unknown UI command: dead_air")
File: Assets/Scripts/Narrative/DialogueParser.cs
TROVA l'enum UICommandType (circa riga 28):
public enum UICommandType
{
None = 0, // Default
DeadAirScreen = 1, // #ui:dead_air_screen
ReturnToMenu = 2 // #ui:return_to_menu
}AGGIUNGI il nuovo comando (esempio: schermata di pausa):
public enum UICommandType
{
None = 0, // Default
DeadAirScreen = 1, // #ui:dead_air_screen
ReturnToMenu = 2, // #ui:return_to_menu
PauseScreen = 3 // #ui:pause_screen ← NUOVO COMANDO
}None = 0deve essere sempre il primo valore (default sicuro)- Numera progressivamente: 1, 2, 3, 4...
- Aggiungi commento con il tag Ink corrispondente
File: Assets/Scripts/Narrative/DialogueParser.cs
TROVA il metodo ParseTags (circa riga 180), blocco UI TAG:
else if (trimmedTag.StartsWith(TAG_UI))
{
string? uiValue = ExtractValue(trimmedTag, TAG_UI);
result = new ParsedLine
{
// ... altri campi ...
UICommand = uiValue?.ToLowerInvariant() switch
{
"dead_air_screen" => UICommandType.DeadAirScreen,
"return_to_menu" => UICommandType.ReturnToMenu,
_ => UICommandType.None
}
};
}AGGIUNGI il case per il nuovo comando:
UICommand = uiValue?.ToLowerInvariant() switch
{
"dead_air_screen" => UICommandType.DeadAirScreen,
"return_to_menu" => UICommandType.ReturnToMenu,
"pause_screen" => UICommandType.PauseScreen, // ← AGGIUNGI QUESTO
_ => UICommandType.None
}File: Assets/Scripts/Narrative/StoryManager.cs
TROVA il metodo ProcessLine (circa riga 233), blocco UI EVENTS:
if (parsed.HasUICommand)
{
string? commandString = parsed.UICommand switch
{
UICommandType.DeadAirScreen => "dead_air_screen",
UICommandType.ReturnToMenu => "return_to_menu",
_ => null
};
if (commandString != null)
uiCommandChannel.RaiseEvent(commandString);
}AGGIUNGI il case per il nuovo comando:
string? commandString = parsed.UICommand switch
{
UICommandType.DeadAirScreen => "dead_air_screen",
UICommandType.ReturnToMenu => "return_to_menu",
UICommandType.PauseScreen => "pause_screen", // ← AGGIUNGI QUESTO
_ => null
};File: Assets/Scripts/UI/DialogueUI.cs
TROVA il metodo HandleUICommand (circa riga 193):
private void HandleUICommand(string command)
{
UICommandType commandType = command?.ToLowerInvariant() switch
{
"dead_air_screen" => UICommandType.DeadAirScreen,
"return_to_menu" => UICommandType.ReturnToMenu,
_ => UICommandType.None
};
switch (commandType)
{
case UICommandType.DeadAirScreen:
ShowDeadAirScreen();
break;
case UICommandType.ReturnToMenu:
QuitApplication();
break;
case UICommandType.None:
Debug.LogWarning($"[DialogueUI] Comando UI sconosciuto: {command}");
break;
}
}AGGIUNGI il parsing e il case:
// STEP 4.1 — Aggiungi parsing
UICommandType commandType = command?.ToLowerInvariant() switch
{
"dead_air_screen" => UICommandType.DeadAirScreen,
"return_to_menu" => UICommandType.ReturnToMenu,
"pause_screen" => UICommandType.PauseScreen, // ← AGGIUNGI QUESTO
_ => UICommandType.None
};
// STEP 4.2 — Aggiungi case
switch (commandType)
{
case UICommandType.DeadAirScreen:
ShowDeadAirScreen();
break;
case UICommandType.ReturnToMenu:
QuitApplication();
break;
case UICommandType.PauseScreen: // ← AGGIUNGI QUESTO
ShowPauseScreen();
break;
case UICommandType.None:
Debug.LogWarning($"[DialogueUI] Comando UI sconosciuto: {command}");
break;
}STEP 4.3 — Crea il metodo handler:
private void ShowPauseScreen()
{
if (_pauseScreen != null)
{
StopTypewriter();
HideContinueIndicator();
_pauseScreen.SetActive(true);
Debug.Log("[DialogueUI] Pause screen attivo");
}
}=== critical_moment ===
Ward, you need to make a decision. Now.
* [Pause and think]
# ui:pause_screen
→ ENDFatto! Il comando è ora type-safe end-to-end.
| Aspetto | Prima (Stringhe) | Dopo (Enum) |
|---|---|---|
| Typo Protection | ❌ "dead_air_screeen" = silent fail |
✅ Compile error se enum sbagliato |
| Refactoring | ❌ Find/Replace manuale | ✅ Rename automatico IDE |
| Exhaustiveness | ❌ Switch può mancare casi | ✅ Compilatore avvisa se manca un case |
| Autocomplete | ❌ Nessuno | ✅ IDE suggerisce valori enum |
| Debugging | ❌ "Unknown command: X" | ✅ Stacktrace preciso + enum value |
- STEP 1: Aggiungi valore a
UICommandTypeenum (DialogueParser.cs) - STEP 2: Aggiungi parsing string → enum (DialogueParser.cs,
ParseTags) - STEP 3: Aggiungi conversione enum → string (StoryManager.cs,
ProcessLine) - STEP 4: Aggiungi parsing + case (DialogueUI.cs,
HandleUICommand) - STEP 5: Implementa metodo handler (DialogueUI.cs, es.
ShowPauseScreen) - TEST: Usa
#ui:{comando}in file Ink e verifica funzionamento
Tempo stimato: 10 minuti per comando.
Perché 4 punti di modifica?
- DialogueParser: Converte stringa Ink → enum (single source of truth)
- StoryManager: Converte enum → stringa per Event Channel (legacy compatibility)
- DialogueUI: Converte stringa → enum per type safety + implementa logica
Futuro: Migrare Event Channels a usare UICommandType direttamente eliminerebbe STEP 3 e 4.1.
Pattern Simile: Usa la stessa strategia per SpeakerType enum se aggiungi nuovi personaggi.
PLAYER CLICCA "CONTINUA"
↓
DialogueUI pubblica su ContinueRequestedChannel
↓
StoryManager riceve evento
↓
StoryManager avanza la storia Ink
↓
StoryManager legge tag (#speaker:iris #voice:iris_01)
↓
StoryManager pubblica su SpeakerLineChannel e VoiceRequestedChannel
↓
DialogueUI riceve da SpeakerLineChannel → Mostra testo
VoiceManager riceve da VoiceRequestedChannel → Riproduce audio
Nessun sistema parla direttamente con un altro → tutto passa attraverso i Channels
| Sistema | Cosa Fa | Ascolta (IN) | Pubblica (OUT) |
|---|---|---|---|
| StoryManager | Coordina tutto, legge Ink | ContinueRequested, ChoiceSelected | DialogueLine, SpeakerLine, SFX, Ambience, Voice, UI, StoryEnd |
| DialogueUI | Mostra testo e scelte | DialogueLine, SpeakerLine, ChoicesPresented, UI, StoryEnd | ContinueRequested, ChoiceSelected, VoiceStop |
| AudioManager | SFX e Ambience (via Libraries) | SFXRequested, AmbienceStart, AmbienceStop | Nessuno |
| VoiceManager | Voci personaggi (via Libraries) | VoiceRequested, VoiceStop | VoiceStarted, VoiceFinished |
Checklist:
- ✅ Tag scritto correttamente nel file .ink? (
#voice:iris_01NON# voice: iris_01) - ✅ Channel asset creato?
- ✅ Channel assegnato in StoryManager?
- ✅ Channel assegnato nel Manager che lo ascolta?
- ✅ File audio presente nella cartella Media?
Checklist:
- ✅ AudioManager ha l'AudioSource assegnato?
- ✅ AudioManager ha la Library corretta assegnata? (Inspector → SFX Libraries / Ambience Libraries)
- ✅ La Library contiene il clip con l'ID corretto? (Apri Library asset → verifica ID)
- ✅ Nome file corrisponde all'ID nella Library? (
#sfx:phone_ring→ ID "phone_ring" in Library) - ✅ Volume AudioSource > 0?
- ✅ Console mostra
[AudioClipLibrary] X → N clip caricati?
Se Console mostra [AudioManager] Totale caricato: 0 SFX:
- Verifica che la Library sia assegnata nell'Inspector di AudioManager
- Verifica che la Library contenga clip (non sia vuota)
Checklist:
- ✅ DialogueUI ha il TextMeshPro assegnato nel campo
_dialogueText? - ✅ DialogueUI ha il channel
dialogueLineChannelassegnato? - ✅ Canvas è attivo nella scena?
| File | Cosa Contiene |
|---|---|
DialogueParser.cs |
Parsing di tutti i tag |
StoryManager.cs |
Coordinazione generale, avanzamento storia |
DialogueUI.cs |
Visualizzazione testo e scelte |
AudioManager.cs |
SFX e Ambience |
VoiceManager.cs |
Voci personaggi |
- Campi serializzati:
[SerializeField] private NomeType _nomeCampo; - Metodi pubblici:
PascalCase(es.PlayMusic) - Metodi privati:
PascalCase(es.HandleMusicRequested) - Event handlers: Prefisso
Handle(es.HandleDialogueLine) - Costanti:
ALL_CAPS(es.TAG_MUSIC)
private void OnEnable()
{
// Subscribe ai channels qui
channel.Subscribe(Handler);
}
private void OnDisable()
{
// SEMPRE Unsubscribe per evitare memory leak
channel.Unsubscribe(Handler);
}Completati ✅:
- Sistema Audio Libraries (ScriptableObject-based)
- Singleton AudioManager con hot-reload per scene
- Ottimizzazione formati audio (ADPCM, Vorbis, Streaming)
- Type-Safe Enums per Speaker e UI Commands (Aprile 2026)
- Refactoring DialogueParser: readonly struct, nullable strings, proprietà derivate
Da Fare:
- Typed Event Channels (SpeakerType, UICommandType nativi)
- Auto-populate Libraries da cartelle (Editor script)
- Sistema salvataggio progressi
- Menu principale completo
- Sistema multiple storie (menu selezione)
- Library Validation Tool (check duplicate IDs)
Possibili Nuovi Tag:
#music:{id}→ Musica di background#camera_shake:{intensity}→ Scuote la camera#fade:{type}→ Transizioni schermo
Developer: Michele Grimaldi
Studio: E-C-H-O SYSTEMS
Progetto: DEAD AIR
DEAD AIR usa ottimizzazioni specifiche per tipo audio seguendo Unity best practices:
| Tipo | Load Type | Compression | Sample Rate | Mono/Stereo | Memoria |
|---|---|---|---|---|---|
| Voice | Decompress On Load | ADPCM | Optimize (~22 kHz) | Mono | ~120 KB per 5s |
| SFX | Decompress On Load | ADPCM | Optimize (~22 kHz) | Mono | ~50 KB per 2s |
| Ambience | Streaming | Vorbis 70% | 44.1 kHz | Stereo | ~200 KB buffer |
| Music | Streaming | Vorbis 80% | 44.1 kHz | Stereo | ~200 KB buffer |
ADPCM per Voice/SFX:
- Compressione 3.5x vs PCM
- CPU overhead minimo (+5% vs PCM)
- Qualità 95% (dialoghi tollerano artefatti)
- Zero latency (decompresso in RAM)
Vorbis Streaming per Ambience/Music:
- Compressione ~10x vs PCM
- Memoria fixed (~200 KB buffer, non dipende da durata clip)
- Streaming da disco (no spike memoria)
- Qualità 90-95% (accettabile per loop ambiente)
Optimize Sample Rate:
- Unity analizza frequenze audio
- Riduce automaticamente sample rate se possibile (es. 44.1 kHz → 22 kHz)
- Risparmio 50% memoria senza perdita qualità percepibile
Memory (Idle): ~2 MB (target: <5 MB) ✅
Memory (Playing): ~5 MB (target: <10 MB) ✅
CPU Audio: <1 ms/frame (target: <2 ms) ✅
Disk Size: ~15 MB (target: <50 MB) ✅
Load Time: <20 ms (target: <50 ms) ✅
Unity auto-applica import settings in base alla cartella del file:
- File in
Audio/Voice/→ ADPCM, Mono, Optimize - File in
Audio/SFX/→ ADPCM, Mono, Optimize - File in
Audio/Ambience/→ Vorbis 70%, Streaming, Stereo
Nessun setup manuale richiesto (gestito da AudioImportProcessor script).
Versione Documento: 2.2 (Aprile 2026)
Architettura: Event Channels + Audio Libraries (ScriptableObject) + Type-Safe Enums
Ultima Modifica: 06 Aprile 2026
Versione Build: 0.8 (Refactoring C# Types)
