Major gameplay, UI, and architecture overhaul

Expanded game data model, versioned saves with migration, and robust inventory/archive systems. Added procedural/file-based audio, new overlays (inventory, archive, settings, confirmations), autosave indicator, and improved transitions. Introduced data validation project, updated localization, enhanced accessibility, and revised documentation. Comprehensive refactor for maintainability and user experience.
This commit is contained in:
Michal Vrba
2026-08-27 22:05:09 +02:00
parent d5e7df6b0e
commit cd3874c7be
18 changed files with 829 additions and 94 deletions
+3
View File
@@ -2,4 +2,7 @@
<Folder Name="/src/"> <Folder Name="/src/">
<Project Path="src/NullDepth/NullDepth.csproj" /> <Project Path="src/NullDepth/NullDepth.csproj" />
</Folder> </Folder>
<Folder Name="/tests/">
<Project Path="tests/NullDepth.Validation/NullDepth.Validation.csproj" />
</Folder>
</Solution> </Solution>
+8
View File
@@ -31,4 +31,12 @@ Build celej solution:
dotnet build NullDepth.slnx -c Release dotnet build NullDepth.slnx -c Release
``` ```
Automatická kontrola dát a všetkých hlavných herných ciest:
```powershell
dotnet run --project tests\NullDepth.Validation\NullDepth.Validation.csproj -c Release
```
Herný obsah upravíte v `src/NullDepth/wwwroot/data/game.json`; slovenské a anglické texty sú v `src/NullDepth/wwwroot/localization`. Herný obsah upravíte v `src/NullDepth/wwwroot/data/game.json`; slovenské a anglické texty sú v `src/NullDepth/wwwroot/localization`.
Aktuálny prototyp obsahuje archív nájdených logov, dátovo definovaný inventár, fade a alarm prechody, autosave indikátor, potvrdenie novej hry, mazanie save a migráciu starších uložených dát.
+15 -1
View File
@@ -7,6 +7,8 @@
- HTML/CSS fullscreen UI - HTML/CSS fullscreen UI
- JSON obsah bez backendu a databázy - JSON obsah bez backendu a databázy
- browser localStorage pre save a settings - browser localStorage pre save a settings
- Web Audio/HTML Audio vrstva s procedurálnym fallbackom
- Browser Fullscreen API ovládané zo Settings
## Princíp ## Princíp
@@ -24,13 +26,16 @@ Input / kliknutie
```text ```text
Game/GameService.cs načítanie dát, pohyb, flagy, save Game/GameService.cs načítanie dát, pohyb, flagy, save
Game/AudioService.cs ambienty, SFX a live master volume
Models/GameModels.cs dátové modely Models/GameModels.cs dátové modely
Pages/Home.razor fullscreen obrazovky a overlaye Pages/Home.razor fullscreen obrazovky a overlaye
wwwroot/js/null-depth.js audio runtime a fullscreen interop
wwwroot/data/game.json celý level a gameplay obsah wwwroot/data/game.json celý level a gameplay obsah
wwwroot/localization/sk.json slovenské texty wwwroot/localization/sk.json slovenské texty
wwwroot/localization/en.json anglické texty wwwroot/localization/en.json anglické texty
wwwroot/assets/images/... title a miestnosti wwwroot/assets/images/... title a miestnosti
wwwroot/assets/audio/... ambience a SFX sloty wwwroot/assets/audio/... ambience a SFX sloty
tests/NullDepth.Validation offline dátové a gameplay testy
``` ```
## RoomDefinition ## RoomDefinition
@@ -42,13 +47,22 @@ Interakcia môže:
- vyžadovať alebo zakazovať flagy - vyžadovať alebo zakazovať flagy
- nastaviť flagy - nastaviť flagy
- pridať predmet - pridať predmet
- uložiť nájdený záznam do trvalého archívu
- vytvoriť checkpoint - vytvoriť checkpoint
- ponúknuť dátové voľby - ponúknuť dátové voľby
- spustiť smrť alebo ending - spustiť smrť alebo ending
## GameState ## GameState
Ukladá aktuálnu miestnosť, navštívené miestnosti, flagy, inventár, checkpoint a čas uloženia. Save používa kľúč `null-depth.save.v1`; settings používajú `null-depth.settings.v1`. Ukladá aktuálnu miestnosť, navštívené miestnosti, flagy, inventár, archív logov, checkpoint, dokončený ending a čas uloženia. Save používa kľúč `null-depth.save.v1`, ale vnútorná verzia stavu je `3` a starší stav sa automaticky migruje. Settings používajú `null-depth.settings.v1`.
Nová hra pred prepísaním vyžaduje potvrdenie. Save možno vymazať v Settings. Poškodený alebo budúcou verziou nekompatibilný stav sa nikdy automaticky neprepíše; UI ponúkne jeho explicitné odstránenie.
Ak je v miestnosti viac dostupných interakcií, `E` otvorí dátovo naplnený picker. Ovláda sa šípkami, Enterom, číslicami alebo myšou.
Pohyb používa krátky fade, dôležité systémové zásahy alarmové bliknutie a text miestnosti jemný reveal. Každý zápis stavu vyvolá viditeľný autosave indikátor.
`game.json` definuje aj `items`, `initialItems` a `archive`. Interakcia s `archiveId` pridá záznam do hráčovho archívu a ten zostáva dostupný cez `L` aj po zmiznutí jednorazovej interakcie.
## Rozšírenie hry ## Rozšírenie hry
+21
View File
@@ -0,0 +1,21 @@
using Microsoft.JSInterop;
namespace NullDepth.Game;
public sealed class AudioService(IJSRuntime js)
{
public async ValueTask InitializeAsync(int volume) =>
await js.InvokeVoidAsync("nullDepth.audio.initialize", volume);
public async ValueTask SetVolumeAsync(int volume) =>
await js.InvokeVoidAsync("nullDepth.audio.setMasterVolume", volume);
public async ValueTask PlayAmbientAsync(string? source) =>
await js.InvokeVoidAsync("nullDepth.audio.playAmbient", source);
public async ValueTask PlaySfxAsync(string? source) =>
await js.InvokeVoidAsync("nullDepth.audio.playSfx", source);
public async ValueTask StopAsync() =>
await js.InvokeVoidAsync("nullDepth.audio.stopAll");
}
+121 -11
View File
@@ -7,6 +7,7 @@ namespace NullDepth.Game;
public sealed class GameService(HttpClient http, IJSRuntime js) public sealed class GameService(HttpClient http, IJSRuntime js)
{ {
private const int CurrentSaveVersion = 3;
private const string SaveKey = "null-depth.save.v1"; private const string SaveKey = "null-depth.save.v1";
private const string SettingsKey = "null-depth.settings.v1"; private const string SettingsKey = "null-depth.settings.v1";
private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web); private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
@@ -16,6 +17,8 @@ public sealed class GameService(HttpClient http, IJSRuntime js)
public GameState State { get; private set; } = new(); public GameState State { get; private set; } = new();
public PlayerSettings Settings { get; private set; } = new(); public PlayerSettings Settings { get; private set; } = new();
public bool HasSave { get; private set; } public bool HasSave { get; private set; }
public SaveLoadResult LastLoadResult { get; private set; } = SaveLoadResult.Missing;
public event Action? Saved;
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
@@ -25,12 +28,16 @@ public sealed class GameService(HttpClient http, IJSRuntime js)
Language = await js.InvokeAsync<string>("nullDepth.browserLanguage") Language = await js.InvokeAsync<string>("nullDepth.browserLanguage")
}; };
await SetLanguageAsync(Settings.Language, persist: false); await SetLanguageAsync(Settings.Language, persist: false);
HasSave = !string.IsNullOrWhiteSpace(await js.InvokeAsync<string?>("localStorage.getItem", SaveKey)); var rawSave = await js.InvokeAsync<string?>("localStorage.getItem", SaveKey);
HasSave = !string.IsNullOrWhiteSpace(rawSave);
LastLoadResult = InspectSave(rawSave);
} }
public string T(string key) => _texts.TryGetValue(key, out var text) ? text : key; public string T(string key) => _texts.TryGetValue(key, out var text) ? text : key;
public RoomDefinition CurrentRoom => Data.Rooms.First(r => r.Id == State.CurrentRoomId); public RoomDefinition CurrentRoom => Data.Rooms.First(r => r.Id == State.CurrentRoomId);
public RoomDefinition? FindRoom(string id) => Data.Rooms.FirstOrDefault(r => r.Id == id); public RoomDefinition? FindRoom(string id) => Data.Rooms.FirstOrDefault(r => r.Id == id);
public ItemDefinition? FindItem(string id) => Data.Items.FirstOrDefault(i => i.Id == id);
public ArchiveDefinition? FindArchive(string id) => Data.Archive.FirstOrDefault(a => a.Id == id);
public bool HasAll(IEnumerable<string> flags) => flags.All(State.Flags.Contains); public bool HasAll(IEnumerable<string> flags) => flags.All(State.Flags.Contains);
public async Task SetLanguageAsync(string language, bool persist = true) public async Task SetLanguageAsync(string language, bool persist = true)
@@ -42,20 +49,92 @@ public sealed class GameService(HttpClient http, IJSRuntime js)
public async Task NewGameAsync() public async Task NewGameAsync()
{ {
State = new GameState { CurrentRoomId = Data.StartRoomId }; State = new GameState
{
CurrentRoomId = Data.StartRoomId,
ContentVersion = Data.Version,
Inventory = [.. Data.InitialItems]
};
State.VisitedRooms.Add(State.CurrentRoomId); State.VisitedRooms.Add(State.CurrentRoomId);
SetCheckpoint(); SetCheckpoint();
await SaveAsync(); await SaveAsync();
} }
public async Task<bool> ContinueAsync() public async Task<SaveLoadResult> ContinueAsync()
{ {
var loaded = await LoadValueAsync<GameState>(SaveKey); var raw = await js.InvokeAsync<string?>("localStorage.getItem", SaveKey);
if (loaded is null || FindRoom(loaded.CurrentRoomId) is null) return false; if (string.IsNullOrWhiteSpace(raw)) return LastLoadResult = SaveLoadResult.Missing;
GameState? loaded;
try { loaded = JsonSerializer.Deserialize<GameState>(raw, _json); }
catch { return LastLoadResult = SaveLoadResult.Corrupt; }
if (loaded is null) return LastLoadResult = SaveLoadResult.Corrupt;
if (loaded.Version > CurrentSaveVersion || loaded.ContentVersion > Data.Version)
return LastLoadResult = SaveLoadResult.Incompatible;
var migrated = loaded.Version < CurrentSaveVersion || loaded.ContentVersion < Data.Version;
Migrate(loaded);
State = loaded; State = loaded;
State.VisitedRooms.Add(State.CurrentRoomId); State.VisitedRooms.Add(State.CurrentRoomId);
HasSave = true; if (migrated) await SaveAsync();
return true; else HasSave = true;
return LastLoadResult = SaveLoadResult.Success;
}
private void Migrate(GameState state)
{
state.VisitedRooms ??= [];
state.Flags ??= [];
state.Inventory ??= [];
state.Archive ??= [];
if (state.Version < 2)
{
if (state.Flags.Contains("elevator_cycle_complete")) state.Flags.Remove("elevator_stalled");
if (state.Flags.Contains("core_shutdown")) state.CompletedEnding = "shutdown";
else if (state.Flags.Contains("external_network_restored")) state.CompletedEnding = "release";
else if (state.Flags.Contains("containment_active")) state.CompletedEnding = "containment";
}
if (state.Version < 3)
{
foreach (var archive in Data.Archive.Where(a => state.Flags.Contains($"{a.Id}_found") ||
(a.Id == "containment_record" && state.Flags.Contains("containment_seen"))))
if (!state.Archive.Contains(archive.Id)) state.Archive.Add(archive.Id);
foreach (var item in Data.InitialItems)
if (!state.Inventory.Contains(item)) state.Inventory.Add(item);
if (state.Checkpoint is not null)
{
state.Checkpoint.VisitedRooms ??= [];
state.Checkpoint.Flags ??= [];
state.Checkpoint.Inventory ??= [];
state.Checkpoint.Archive ??= [];
foreach (var item in Data.InitialItems)
if (!state.Checkpoint.Inventory.Contains(item)) state.Checkpoint.Inventory.Add(item);
foreach (var entry in Data.Archive.Where(a => state.Checkpoint.Flags.Contains($"{a.Id}_found") ||
(a.Id == "containment_record" && state.Checkpoint.Flags.Contains("containment_seen"))))
if (!state.Checkpoint.Archive.Contains(entry.Id)) state.Checkpoint.Archive.Add(entry.Id);
}
}
if (FindRoom(state.CurrentRoomId) is null) state.CurrentRoomId = Data.StartRoomId;
state.VisitedRooms.RemoveWhere(id => FindRoom(id) is null);
state.Inventory = state.Inventory.Where(id => FindItem(id) is not null).Distinct().ToList();
state.Archive = state.Archive.Where(id => FindArchive(id) is not null).Distinct().ToList();
if (state.Checkpoint is not null)
{
if (FindRoom(state.Checkpoint.RoomId) is null) state.Checkpoint.RoomId = Data.StartRoomId;
state.Checkpoint.VisitedRooms ??= [];
state.Checkpoint.Flags ??= [];
state.Checkpoint.Inventory ??= [];
state.Checkpoint.Archive ??= [];
state.Checkpoint.VisitedRooms.RemoveWhere(id => FindRoom(id) is null);
state.Checkpoint.Inventory = state.Checkpoint.Inventory.Where(id => FindItem(id) is not null).Distinct().ToList();
state.Checkpoint.Archive = state.Checkpoint.Archive.Where(id => FindArchive(id) is not null).Distinct().ToList();
}
state.Version = CurrentSaveVersion;
state.ContentVersion = Data.Version;
} }
public async Task<string?> MoveAsync(string direction) public async Task<string?> MoveAsync(string direction)
@@ -73,14 +152,17 @@ public sealed class GameService(HttpClient http, IJSRuntime js)
public async Task ApplyInteractionAsync(InteractionDefinition interaction) public async Task ApplyInteractionAsync(InteractionDefinition interaction)
{ {
Apply(interaction.SetFlags, interaction.AddItems); Apply(interaction.SetFlags, interaction.ClearFlags, interaction.AddItems);
if (!string.IsNullOrWhiteSpace(interaction.ArchiveId) && !State.Archive.Contains(interaction.ArchiveId))
State.Archive.Add(interaction.ArchiveId);
if (interaction.Checkpoint) SetCheckpoint(); if (interaction.Checkpoint) SetCheckpoint();
await SaveAsync(); await SaveAsync();
} }
public async Task ApplyChoiceAsync(ChoiceDefinition choice) public async Task ApplyChoiceAsync(ChoiceDefinition choice)
{ {
Apply(choice.SetFlags, choice.AddItems); Apply(choice.SetFlags, choice.ClearFlags, choice.AddItems);
if (!string.IsNullOrWhiteSpace(choice.Ending)) State.CompletedEnding = choice.Ending;
if (choice.Checkpoint) SetCheckpoint(); if (choice.Checkpoint) SetCheckpoint();
await SaveAsync(); await SaveAsync();
} }
@@ -93,6 +175,8 @@ public sealed class GameService(HttpClient http, IJSRuntime js)
State.VisitedRooms = [.. checkpoint.VisitedRooms]; State.VisitedRooms = [.. checkpoint.VisitedRooms];
State.Flags = [.. checkpoint.Flags]; State.Flags = [.. checkpoint.Flags];
State.Inventory = [.. checkpoint.Inventory]; State.Inventory = [.. checkpoint.Inventory];
State.Archive = [.. checkpoint.Archive];
State.CompletedEnding = null;
await SaveAsync(); await SaveAsync();
} }
@@ -108,10 +192,21 @@ public sealed class GameService(HttpClient http, IJSRuntime js)
State.SavedAt = DateTimeOffset.UtcNow; State.SavedAt = DateTimeOffset.UtcNow;
await StoreValueAsync(SaveKey, State); await StoreValueAsync(SaveKey, State);
HasSave = true; HasSave = true;
LastLoadResult = SaveLoadResult.Success;
Saved?.Invoke();
} }
private void Apply(IEnumerable<string> flags, IEnumerable<string> items) public async Task DeleteSaveAsync()
{ {
await js.InvokeVoidAsync("localStorage.removeItem", SaveKey);
State = new GameState { CurrentRoomId = Data.StartRoomId, ContentVersion = Data.Version };
HasSave = false;
LastLoadResult = SaveLoadResult.Missing;
}
private void Apply(IEnumerable<string> flags, IEnumerable<string> clearFlags, IEnumerable<string> items)
{
foreach (var flag in clearFlags) State.Flags.Remove(flag);
foreach (var flag in flags) State.Flags.Add(flag); foreach (var flag in flags) State.Flags.Add(flag);
foreach (var item in items) if (!State.Inventory.Contains(item)) State.Inventory.Add(item); foreach (var item in items) if (!State.Inventory.Contains(item)) State.Inventory.Add(item);
} }
@@ -121,9 +216,24 @@ public sealed class GameService(HttpClient http, IJSRuntime js)
RoomId = State.CurrentRoomId, RoomId = State.CurrentRoomId,
VisitedRooms = [.. State.VisitedRooms], VisitedRooms = [.. State.VisitedRooms],
Flags = [.. State.Flags], Flags = [.. State.Flags],
Inventory = [.. State.Inventory] Inventory = [.. State.Inventory],
Archive = [.. State.Archive]
}; };
private SaveLoadResult InspectSave(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return SaveLoadResult.Missing;
try
{
var save = JsonSerializer.Deserialize<GameState>(raw, _json);
if (save is null) return SaveLoadResult.Corrupt;
return save.Version > CurrentSaveVersion || save.ContentVersion > Data.Version
? SaveLoadResult.Incompatible
: SaveLoadResult.Success;
}
catch { return SaveLoadResult.Corrupt; }
}
private async Task<T?> LoadValueAsync<T>(string key) private async Task<T?> LoadValueAsync<T>(string key)
{ {
var raw = await js.InvokeAsync<string?>("localStorage.getItem", key); var raw = await js.InvokeAsync<string?>("localStorage.getItem", key);
+38 -1
View File
@@ -2,16 +2,36 @@ namespace NullDepth.Models;
public sealed class GameData public sealed class GameData
{ {
public int Version { get; set; } = 1;
public string StartRoomId { get; set; } = "loc_01_exterior_access"; public string StartRoomId { get; set; } = "loc_01_exterior_access";
public List<string> InitialItems { get; set; } = [];
public List<ItemDefinition> Items { get; set; } = [];
public List<ArchiveDefinition> Archive { get; set; } = [];
public List<RoomDefinition> Rooms { get; set; } = []; public List<RoomDefinition> Rooms { get; set; } = [];
} }
public sealed class ItemDefinition
{
public string Id { get; set; } = "";
public string NameKey { get; set; } = "";
public string DescriptionKey { get; set; } = "";
public string Category { get; set; } = "equipment";
}
public sealed class ArchiveDefinition
{
public string Id { get; set; } = "";
public string TitleKey { get; set; } = "";
public string ContentKey { get; set; } = "";
}
public sealed class RoomDefinition public sealed class RoomDefinition
{ {
public string Id { get; set; } = ""; public string Id { get; set; } = "";
public string NameKey { get; set; } = ""; public string NameKey { get; set; } = "";
public string DescriptionKey { get; set; } = ""; public string DescriptionKey { get; set; } = "";
public string Image { get; set; } = ""; public string Image { get; set; } = "";
public string? Ambient { get; set; }
public string? AlternateImage { get; set; } public string? AlternateImage { get; set; }
public string? AlternateImageFlag { get; set; } public string? AlternateImageFlag { get; set; }
public int X { get; set; } public int X { get; set; }
@@ -36,7 +56,10 @@ public sealed class InteractionDefinition
public List<string> RequiresFlags { get; set; } = []; public List<string> RequiresFlags { get; set; } = [];
public List<string> ForbiddenFlags { get; set; } = []; public List<string> ForbiddenFlags { get; set; } = [];
public List<string> SetFlags { get; set; } = []; public List<string> SetFlags { get; set; } = [];
public List<string> ClearFlags { get; set; } = [];
public List<string> AddItems { get; set; } = []; public List<string> AddItems { get; set; } = [];
public string? ArchiveId { get; set; }
public string? Sound { get; set; }
public bool Checkpoint { get; set; } public bool Checkpoint { get; set; }
public List<ChoiceDefinition> Choices { get; set; } = []; public List<ChoiceDefinition> Choices { get; set; } = [];
} }
@@ -47,7 +70,9 @@ public sealed class ChoiceDefinition
public string? ResultKey { get; set; } public string? ResultKey { get; set; }
public List<string> RequiresFlags { get; set; } = []; public List<string> RequiresFlags { get; set; } = [];
public List<string> SetFlags { get; set; } = []; public List<string> SetFlags { get; set; } = [];
public List<string> ClearFlags { get; set; } = [];
public List<string> AddItems { get; set; } = []; public List<string> AddItems { get; set; } = [];
public string? Sound { get; set; }
public string? Ending { get; set; } public string? Ending { get; set; }
public bool Death { get; set; } public bool Death { get; set; }
public bool Checkpoint { get; set; } public bool Checkpoint { get; set; }
@@ -55,11 +80,14 @@ public sealed class ChoiceDefinition
public sealed class GameState public sealed class GameState
{ {
public int Version { get; set; } = 1; public int Version { get; set; } = 3;
public int ContentVersion { get; set; } = 1;
public string CurrentRoomId { get; set; } = "loc_01_exterior_access"; public string CurrentRoomId { get; set; } = "loc_01_exterior_access";
public HashSet<string> VisitedRooms { get; set; } = []; public HashSet<string> VisitedRooms { get; set; } = [];
public HashSet<string> Flags { get; set; } = []; public HashSet<string> Flags { get; set; } = [];
public List<string> Inventory { get; set; } = []; public List<string> Inventory { get; set; } = [];
public List<string> Archive { get; set; } = [];
public string? CompletedEnding { get; set; }
public GameSnapshot? Checkpoint { get; set; } public GameSnapshot? Checkpoint { get; set; }
public DateTimeOffset SavedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset SavedAt { get; set; } = DateTimeOffset.UtcNow;
} }
@@ -70,6 +98,7 @@ public sealed class GameSnapshot
public HashSet<string> VisitedRooms { get; set; } = []; public HashSet<string> VisitedRooms { get; set; } = [];
public HashSet<string> Flags { get; set; } = []; public HashSet<string> Flags { get; set; } = [];
public List<string> Inventory { get; set; } = []; public List<string> Inventory { get; set; } = [];
public List<string> Archive { get; set; } = [];
} }
public sealed class PlayerSettings public sealed class PlayerSettings
@@ -77,3 +106,11 @@ public sealed class PlayerSettings
public string Language { get; set; } = "en"; public string Language { get; set; } = "en";
public int MasterVolume { get; set; } = 70; public int MasterVolume { get; set; } = 70;
} }
public enum SaveLoadResult
{
Success,
Missing,
Corrupt,
Incompatible
}
+182 -28
View File
@@ -1,9 +1,12 @@
@page "/" @page "/"
@inject GameService Game @inject GameService Game
@inject AudioService Audio
@inject IJSRuntime JS
@implements IDisposable
<PageTitle>NULL DEPTH</PageTitle> <PageTitle>NULL DEPTH</PageTitle>
<main class="game-shell" tabindex="0" @ref="_shell" @onkeydown="HandleKey" @onkeydown:preventDefault> <main class="game-shell @(_isTransitioning ? "transitioning" : "") @(_systemFlash ? "system-alert" : "")" tabindex="0" @ref="_shell" @onkeydown="HandleKey">
@if (!_ready) @if (!_ready)
{ {
<section class="loading-screen"><span>NULL DEPTH // BOOTING</span></section> <section class="loading-screen"><span>NULL DEPTH // BOOTING</span></section>
@@ -16,8 +19,9 @@
<nav class="title-menu" aria-label="Main menu"> <nav class="title-menu" aria-label="Main menu">
<button class="@MenuClass(0)" @onclick="StartNewGame">@Game.T("menu.newGame")</button> <button class="@MenuClass(0)" @onclick="StartNewGame">@Game.T("menu.newGame")</button>
<button class="@MenuClass(1)" disabled="@(!Game.HasSave)" @onclick="ContinueGame">@Game.T("menu.continue")</button> <button class="@MenuClass(1)" disabled="@(!Game.HasSave)" @onclick="ContinueGame">@Game.T("menu.continue")</button>
<button class="@MenuClass(2)" @onclick="() => _showSettings = true">@Game.T("menu.settings")</button> <button class="@MenuClass(2)" @onclick="OpenSettings">@Game.T("menu.settings")</button>
<small>@Game.T("menu.controlsHint")</small> <small>@Game.T("menu.controlsHint")</small>
@if (!string.IsNullOrWhiteSpace(_titleNotice)) { <p class="title-notice" role="alert">@_titleNotice</p> }
</nav> </nav>
</section> </section>
} }
@@ -44,9 +48,9 @@
var room = Game.CurrentRoom; var room = Game.CurrentRoom;
<section class="play-screen screen-image" style="background-image:url('@Game.RoomImage(room)')"> <section class="play-screen screen-image" style="background-image:url('@Game.RoomImage(room)')">
<div class="vignette"></div> <div class="vignette"></div>
<header class="top-hud"><span>NULL DEPTH</span><span>@Game.T("game.autosave")</span></header> <header class="top-hud"><span>NULL DEPTH</span><span class="autosave-state @(_savePulse ? "saving" : "")">@Game.T(_savePulse ? "game.autosaveSaving" : "game.autosaveSaved")</span></header>
<article class="room-panel"> <article class="room-panel">
<p class="eyebrow">@room.Id.Replace("_", " // ").ToUpperInvariant()</p><h2>@Game.T(room.NameKey)</h2><p>@Game.T(room.DescriptionKey)</p> <p class="eyebrow">@room.Id.Replace("_", " // ").ToUpperInvariant()</p><h2 @key="room.Id">@Game.T(room.NameKey)</h2><p class="reveal-copy" @key="@(room.Id + Game.Settings.Language)">@Game.T(room.DescriptionKey)</p>
@if (!string.IsNullOrWhiteSpace(_notice)) { <p class="notice">@_notice</p> } @if (!string.IsNullOrWhiteSpace(_notice)) { <p class="notice">@_notice</p> }
<div class="interaction-list">@foreach (var action in Game.AvailableInteractions()) { <button @onclick="() => OpenInteraction(action)"><kbd>E</kbd> @Game.T(action.LabelKey)</button> }</div> <div class="interaction-list">@foreach (var action in Game.AvailableInteractions()) { <button @onclick="() => OpenInteraction(action)"><kbd>E</kbd> @Game.T(action.LabelKey)</button> }</div>
</article> </article>
@@ -57,8 +61,8 @@
<button class="south" disabled="@(!HasSouth)" @onclick="MoveSouth">↓</button> <button class="south" disabled="@(!HasSouth)" @onclick="MoveSouth">↓</button>
</nav> </nav>
<footer class="command-bar"> <footer class="command-bar">
<button @onclick="OpenFirstInteraction"><kbd>E</kbd> @Game.T("game.interact")</button><button @onclick="() => _showMap = true"><kbd>M</kbd> @Game.T("game.map")</button> <button @onclick="OpenInteractionSelector"><kbd>E</kbd> @Game.T("game.interact")</button><button @onclick="() => _showMap = true"><kbd>M</kbd> @Game.T("game.map")</button>
<button @onclick="() => _showInventory = true"><kbd>I</kbd> @Game.T("game.inventory")</button><button @onclick="() => _showPause = true"><kbd>ESC</kbd> @Game.T("game.pause")</button> <button @onclick="() => _showInventory = true"><kbd>I</kbd> @Game.T("game.inventory")</button><button @onclick="() => _showArchive = true"><kbd>L</kbd> @Game.T("game.archive")</button><button @onclick="() => _showPause = true"><kbd>ESC</kbd> @Game.T("game.pause")</button>
</footer> </footer>
</section> </section>
} }
@@ -68,7 +72,23 @@
} }
else if (_screen == Screen.Ending) else if (_screen == Screen.Ending)
{ {
<section class="outcome-screen ending-screen"><p class="eyebrow">ENDING // @_ending?.ToUpperInvariant()</p><h1>@Game.T($"ending.{_ending}.title")</h1><p>@Game.T($"ending.{_ending}.body")</p><button class="primary-action" @onclick="ReturnToTitle">@Game.T("ending.return")</button></section> <section class="outcome-screen ending-screen screen-image" style="@EndingImageStyle"><div class="vignette"></div><p class="eyebrow">ENDING // @_ending?.ToUpperInvariant()</p><h1>@Game.T($"ending.{_ending}.title")</h1><p>@Game.T($"ending.{_ending}.body")</p><button class="primary-action" @onclick="ReturnToTitle">@Game.T("ending.return")</button></section>
}
@if (_showInteractionPicker)
{
<div class="modal-layer" role="dialog" aria-modal="true"><article class="overlay-card action-picker">
<button class="close-button" aria-label="Close" @onclick="CloseOverlays">×</button><p class="eyebrow">CONTEXT // E</p><h2>@Game.T("interaction.choose")</h2>
<div class="choice-list">
@for (var index = 0; index < AvailableActions.Count; index++)
{
var actionIndex = index;
var action = AvailableActions[index];
<button class="@(index == _interactionIndex ? "selected" : "")" @onclick="() => SelectInteraction(action)"><kbd>@(index + 1)</kbd> @Game.T(action.LabelKey)</button>
}
</div>
<small>↑ ↓ SELECT // ENTER CONFIRM</small>
</article></div>
} }
@if (_interaction is not null) @if (_interaction is not null)
@@ -87,19 +107,39 @@
} }
@if (_showInventory) @if (_showInventory)
{ {
<div class="modal-layer" role="dialog" aria-modal="true"><article class="overlay-card inventory-card"><button class="close-button" aria-label="Close" @onclick="CloseOverlays">×</button><p class="eyebrow">ELIAS VENN // LOADOUT</p><h2>@Game.T("inventory.title")</h2>@if (Game.State.Inventory.Count == 0) { <p>@Game.T("inventory.empty")</p> } else { <ul>@foreach (var item in Game.State.Inventory) { <li>@Game.T($"item.{item}")</li> }</ul> }</article></div> <div class="modal-layer" role="dialog" aria-modal="true"><article class="overlay-card inventory-card"><button class="close-button" aria-label="Close" @onclick="CloseOverlays">×</button><p class="eyebrow">ELIAS VENN // LOADOUT</p><h2>@Game.T("inventory.title")</h2>@if (Game.State.Inventory.Count == 0) { <p>@Game.T("inventory.empty")</p> } else { <ul>@foreach (var itemId in Game.State.Inventory) { var item = Game.FindItem(itemId); if (item is not null) { <li><small>@item.Category.ToUpperInvariant()</small><strong>@Game.T(item.NameKey)</strong><p>@Game.T(item.DescriptionKey)</p></li> } }</ul> }</article></div>
}
@if (_showArchive)
{
<div class="modal-layer" role="dialog" aria-modal="true"><article class="overlay-card archive-card"><button class="close-button" aria-label="Close" @onclick="CloseOverlays">×</button><p class="eyebrow">FACILITY 03 // MEMORY</p><h2>@Game.T("archive.title")</h2>
@if (Game.State.Archive.Count == 0) { <p>@Game.T("archive.empty")</p> }
else { <div class="archive-list">@foreach (var archiveId in Game.State.Archive) { var entry = Game.FindArchive(archiveId); if (entry is not null) { <details><summary>@Game.T(entry.TitleKey)</summary><p class="terminal-copy">@Game.T(entry.ContentKey)</p></details> } }</div> }
</article></div>
} }
@if (_showPause) @if (_showPause)
{ {
<div class="modal-layer" role="dialog" aria-modal="true"><article class="overlay-card pause-card"><p class="eyebrow">SYSTEM // PAUSED</p><h2>@Game.T("pause.title")</h2><button class="primary-action" @onclick="CloseOverlays">@Game.T("pause.resume")</button><button @onclick="() => _showSettings = true">@Game.T("menu.settings")</button><button @onclick="ReturnToTitle">@Game.T("common.mainMenu")</button></article></div> <div class="modal-layer" role="dialog" aria-modal="true"><article class="overlay-card pause-card"><p class="eyebrow">SYSTEM // PAUSED</p><h2>@Game.T("pause.title")</h2><button class="primary-action" @onclick="CloseOverlays">@Game.T("pause.resume")</button><button @onclick="OpenSettings">@Game.T("menu.settings")</button><button @onclick="ReturnToTitle">@Game.T("common.mainMenu")</button></article></div>
} }
@if (_showSettings) @if (_showSettings)
{ {
<div class="modal-layer settings-layer" role="dialog" aria-modal="true"><article class="overlay-card settings-card"><button class="close-button" aria-label="Close" @onclick="CloseSettings">×</button><p class="eyebrow">SYSTEM // CONFIGURATION</p><h2>@Game.T("settings.title")</h2> <div class="modal-layer settings-layer" role="dialog" aria-modal="true"><article class="overlay-card settings-card"><button class="close-button" aria-label="Close" @onclick="CloseSettings">×</button><p class="eyebrow">SYSTEM // CONFIGURATION</p><h2>@Game.T("settings.title")</h2>
<label>@Game.T("settings.language")</label><div class="segmented-control"><button class="@(Game.Settings.Language == "sk" ? "active" : "")" @onclick="ChangeToSlovak">Slovenčina</button><button class="@(Game.Settings.Language == "en" ? "active" : "")" @onclick="ChangeToEnglish">English</button></div> <label>@Game.T("settings.language")</label><div class="segmented-control"><button class="@(Game.Settings.Language == "sk" ? "active" : "")" @onclick="ChangeToSlovak">Slovenčina</button><button class="@(Game.Settings.Language == "en" ? "active" : "")" @onclick="ChangeToEnglish">English</button></div>
<label for="volume">@Game.T("settings.volume") — @Game.Settings.MasterVolume%</label><input id="volume" type="range" min="0" max="100" step="5" @bind="Game.Settings.MasterVolume" @bind:event="oninput" @onchange="SaveSettings" /><button class="primary-action" @onclick="CloseSettings">@Game.T("common.back")</button> <label for="volume">@Game.T("settings.volume") — @Game.Settings.MasterVolume%</label><input id="volume" type="range" min="0" max="100" step="5" value="@Game.Settings.MasterVolume" @oninput="ChangeVolume" />
<label>@Game.T("settings.fullscreen")</label><button class="setting-toggle @(_isFullscreen ? "active" : "")" @onclick="ToggleFullscreen">@Game.T(_isFullscreen ? "settings.exitFullscreen" : "settings.enterFullscreen")</button>
@if (Game.HasSave) { <button class="danger-action" @onclick="RequestDeleteSave">@Game.T("save.delete")</button> }
<button class="primary-action" @onclick="CloseSettings">@Game.T("common.back")</button>
</article></div> </article></div>
} }
@if (_confirmation is not null)
{
<div class="modal-layer confirmation-layer" role="alertdialog" aria-modal="true"><article class="overlay-card confirmation-card"><p class="eyebrow">SYSTEM // CONFIRMATION</p><h2>@Game.T(_confirmation == Confirmation.NewGame ? "save.overwriteTitle" : "save.deleteTitle")</h2><p>@Game.T(_confirmation == Confirmation.NewGame ? "save.overwriteBody" : "save.deleteBody")</p><div class="confirmation-actions"><button class="danger-action" @onclick="ConfirmPendingAction">@Game.T("common.confirm")</button><button @onclick="CancelConfirmation">@Game.T("common.cancel")</button></div></article></div>
}
@if (_saveError is not null)
{
<div class="modal-layer" role="alertdialog" aria-modal="true"><article class="overlay-card confirmation-card"><p class="eyebrow">SAVE // ERROR</p><h2>@Game.T("save.errorTitle")</h2><p>@Game.T(_saveError == SaveLoadResult.Incompatible ? "save.incompatible" : "save.corrupt")</p><div class="confirmation-actions"><button class="danger-action" @onclick="DeleteBrokenSave">@Game.T("save.delete")</button><button @onclick="CloseSaveError">@Game.T("common.back")</button></div></article></div>
}
<div class="system-flash" aria-hidden="true"></div>
<div class="screen-fade" aria-hidden="true"></div>
</main> </main>
@code { @code {
@@ -112,15 +152,50 @@
private InteractionDefinition? _interaction; private InteractionDefinition? _interaction;
private bool _showMap; private bool _showMap;
private bool _showInventory; private bool _showInventory;
private bool _showArchive;
private bool _showPause; private bool _showPause;
private bool _showSettings; private bool _showSettings;
private bool _showInteractionPicker;
private bool _isFullscreen;
private bool _isTransitioning;
private bool _systemFlash;
private bool _savePulse;
private int _savePulseVersion;
private int _interactionIndex;
private string? _titleNotice;
private SaveLoadResult? _saveError;
private Confirmation? _confirmation;
private List<InteractionDefinition> AvailableActions => Game.AvailableInteractions().ToList();
private string EndingImageStyle => $"background-image:url('{Game.RoomImage(Game.CurrentRoom)}')";
protected override async Task OnInitializedAsync() { await Game.InitializeAsync(); _ready = true; } protected override async Task OnInitializedAsync() { await Game.InitializeAsync(); Game.Saved += OnSaved; await Audio.InitializeAsync(Game.Settings.MasterVolume); _ready = true; }
protected override async Task OnAfterRenderAsync(bool firstRender) { if (_ready) await _shell.FocusAsync(); } protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender && _ready) await _shell.FocusAsync(); }
private async Task StartNewGame() { await Game.NewGameAsync(); _screen = Screen.Briefing; _notice = null; } private Task StartNewGame() { if (Game.HasSave) { _confirmation = Confirmation.NewGame; return Task.CompletedTask; } return StartNewGameConfirmed(); }
private async Task ContinueGame() { if (await Game.ContinueAsync()) _screen = Screen.Game; } private async Task StartNewGameConfirmed() { _confirmation = null; _titleNotice = null; await Audio.PlaySfxAsync("procedural:ui_confirm"); await Game.NewGameAsync(); _screen = Screen.Briefing; _notice = null; }
private void BeginPlay() => _screen = Screen.Game; private async Task ContinueGame()
private async Task Move(string direction) { var result = await Game.MoveAsync(direction); if (result is not null) _notice = result; if (result == "") _notice = null; } {
var result = await Game.ContinueAsync();
if (result != SaveLoadResult.Success) { _saveError = result; return; }
_titleNotice = null;
_ending = Game.State.CompletedEnding;
_screen = _ending is null ? Screen.Game : Screen.Ending;
await Audio.PlayAmbientAsync(Game.CurrentRoom.Ambient);
}
private async Task BeginPlay() { _screen = Screen.Game; await Audio.PlayAmbientAsync(Game.CurrentRoom.Ambient); }
private async Task Move(string direction)
{
if (_isTransitioning) return;
if (!Game.CurrentRoom.Exits.TryGetValue(direction, out var exit)) return;
if (!Game.HasAll(exit.RequiresFlags)) { _notice = Game.T(exit.LockedMessageKey ?? "game.exit.locked"); return; }
_isTransitioning = true;
await Audio.PlaySfxAsync("procedural:door_heavy");
await Task.Delay(150);
var result = await Game.MoveAsync(direction);
if (result is not null) _notice = result;
if (result == "") { _notice = null; await Audio.PlayAmbientAsync(Game.CurrentRoom.Ambient); }
await Task.Delay(260);
_isTransitioning = false;
}
private Task MoveNorth() => Move("north"); private Task MoveNorth() => Move("north");
private Task MoveSouth() => Move("south"); private Task MoveSouth() => Move("south");
private Task MoveWest() => Move("west"); private Task MoveWest() => Move("west");
@@ -129,39 +204,118 @@
private bool HasSouth => Game.CurrentRoom.Exits.ContainsKey("south"); private bool HasSouth => Game.CurrentRoom.Exits.ContainsKey("south");
private bool HasWest => Game.CurrentRoom.Exits.ContainsKey("west"); private bool HasWest => Game.CurrentRoom.Exits.ContainsKey("west");
private bool HasEast => Game.CurrentRoom.Exits.ContainsKey("east"); private bool HasEast => Game.CurrentRoom.Exits.ContainsKey("east");
private void OpenFirstInteraction() { var first = Game.AvailableInteractions().FirstOrDefault(); if (first is not null) OpenInteraction(first); } private void OpenInteractionSelector()
private void OpenInteraction(InteractionDefinition interaction) { _interaction = interaction; _notice = null; } {
private async Task ResolveInteraction() { if (_interaction is null) return; await Game.ApplyInteractionAsync(_interaction); _notice = Game.T(_interaction.ContentKey); _interaction = null; } var actions = AvailableActions;
private async Task ResolveChoice(ChoiceDefinition choice) { await Game.ApplyChoiceAsync(choice); _interaction = null; if (choice.Death) { _screen = Screen.Death; return; } if (!string.IsNullOrWhiteSpace(choice.Ending)) { _ending = choice.Ending; _screen = Screen.Ending; return; } _notice = choice.ResultKey is null ? null : Game.T(choice.ResultKey); } if (actions.Count == 0) { _notice = Game.T("interaction.none"); return; }
private async Task RestoreCheckpoint() { await Game.RestoreCheckpointAsync(); _screen = Screen.Game; _notice = Game.T("death.restored"); } if (actions.Count == 1) { OpenInteraction(actions[0]); return; }
private void ReturnToTitle() { CloseOverlays(); _showSettings = false; _screen = Screen.Title; _menuIndex = 0; } _interactionIndex = 0;
_showInteractionPicker = true;
_notice = null;
}
private void OpenInteraction(InteractionDefinition interaction) { _showInteractionPicker = false; _interaction = interaction; _notice = null; }
private void SelectInteraction(InteractionDefinition interaction) => OpenInteraction(interaction);
private async Task ResolveInteraction()
{
if (_interaction is null) return;
var resolved = _interaction;
await Game.ApplyInteractionAsync(resolved);
await Audio.PlaySfxAsync(resolved.Sound);
_interaction = null;
if (resolved.SetFlags.Contains("power_restored") || resolved.Sound == "procedural:alarm_pulse") _ = TriggerSystemFlashAsync();
await Task.Delay(180);
_notice = Game.T(resolved.ContentKey);
}
private async Task ResolveChoice(ChoiceDefinition choice)
{
await Game.ApplyChoiceAsync(choice);
await Audio.PlaySfxAsync(choice.Sound);
_interaction = null;
if (choice.Sound == "procedural:alarm_pulse") _ = TriggerSystemFlashAsync();
if (choice.Death) { _screen = Screen.Death; return; }
if (!string.IsNullOrWhiteSpace(choice.Ending)) { _ending = choice.Ending; _screen = Screen.Ending; return; }
await Task.Delay(180);
_notice = choice.ResultKey is null ? null : Game.T(choice.ResultKey);
}
private async Task RestoreCheckpoint() { await Game.RestoreCheckpointAsync(); _screen = Screen.Game; _notice = Game.T("death.restored"); await Audio.PlayAmbientAsync(Game.CurrentRoom.Ambient); }
private async Task ReturnToTitle() { CloseOverlays(); _showSettings = false; _screen = Screen.Title; _menuIndex = 0; _titleNotice = null; await Audio.StopAsync(); }
private async Task ChangeLanguage(string language) { await Game.SetLanguageAsync(language); StateHasChanged(); } private async Task ChangeLanguage(string language) { await Game.SetLanguageAsync(language); StateHasChanged(); }
private Task ChangeToSlovak() => ChangeLanguage("sk"); private Task ChangeToSlovak() => ChangeLanguage("sk");
private Task ChangeToEnglish() => ChangeLanguage("en"); private Task ChangeToEnglish() => ChangeLanguage("en");
private async Task SaveSettings(ChangeEventArgs _) => await Game.SaveSettingsAsync(); private async Task ChangeVolume(ChangeEventArgs args) { if (!int.TryParse(args.Value?.ToString(), out var volume)) return; Game.Settings.MasterVolume = volume; await Audio.SetVolumeAsync(volume); await Game.SaveSettingsAsync(); }
private async Task OpenSettings() { _isFullscreen = await JS.InvokeAsync<bool>("nullDepth.isFullscreen"); _showSettings = true; }
private async Task ToggleFullscreen() { _isFullscreen = await JS.InvokeAsync<bool>("nullDepth.toggleFullscreen"); }
private async Task CloseSettings() { await Game.SaveSettingsAsync(); _showSettings = false; } private async Task CloseSettings() { await Game.SaveSettingsAsync(); _showSettings = false; }
private void CloseOverlays() { _interaction = null; _showMap = false; _showInventory = false; _showPause = false; } private void CloseOverlays() { _interaction = null; _showInteractionPicker = false; _showMap = false; _showInventory = false; _showArchive = false; _showPause = false; }
private void RequestDeleteSave() => _confirmation = Confirmation.DeleteSave;
private async Task ConfirmPendingAction()
{
if (_confirmation == Confirmation.NewGame) await StartNewGameConfirmed();
else if (_confirmation == Confirmation.DeleteSave)
{
await Game.DeleteSaveAsync();
_confirmation = null;
CloseOverlays();
_showSettings = false;
_screen = Screen.Title;
_menuIndex = 0;
_titleNotice = Game.T("save.deleted");
await Audio.StopAsync();
}
}
private void CancelConfirmation() => _confirmation = null;
private async Task DeleteBrokenSave() { await Game.DeleteSaveAsync(); _saveError = null; _titleNotice = Game.T("save.deleted"); }
private void CloseSaveError() => _saveError = null;
private void OnSaved() => _ = InvokeAsync(PulseSaveAsync);
private async Task PulseSaveAsync()
{
var version = ++_savePulseVersion;
_savePulse = true;
StateHasChanged();
await Task.Delay(900);
if (version == _savePulseVersion) { _savePulse = false; StateHasChanged(); }
}
private async Task TriggerSystemFlashAsync()
{
_systemFlash = true;
await InvokeAsync(StateHasChanged);
await Task.Delay(650);
_systemFlash = false;
await InvokeAsync(StateHasChanged);
}
private string MenuClass(int index) => _menuIndex == index ? "selected" : ""; private string MenuClass(int index) => _menuIndex == index ? "selected" : "";
private async Task HandleKey(KeyboardEventArgs args) private async Task HandleKey(KeyboardEventArgs args)
{ {
if (!_ready) return; if (!_ready) return;
if (_confirmation is not null || _saveError is not null) { if (args.Key == "Escape") { _confirmation = null; _saveError = null; } return; }
if (_showSettings) { if (args.Key == "Escape") await CloseSettings(); return; } if (_showSettings) { if (args.Key == "Escape") await CloseSettings(); return; }
if (_interaction is not null || _showMap || _showInventory) { if (args.Key == "Escape") CloseOverlays(); return; } if (_showInteractionPicker)
{
var actions = AvailableActions;
if (args.Key == "Escape") CloseOverlays();
else if (args.Key is "ArrowDown" or "ArrowUp") { var delta = args.Key == "ArrowDown" ? 1 : -1; _interactionIndex = (_interactionIndex + delta + actions.Count) % actions.Count; }
else if (args.Key == "Enter" && actions.Count > 0) OpenInteraction(actions[_interactionIndex]);
else if (int.TryParse(args.Key, out var number) && number > 0 && number <= actions.Count) OpenInteraction(actions[number - 1]);
return;
}
if (_interaction is not null || _showMap || _showInventory || _showArchive) { if (args.Key == "Escape") CloseOverlays(); return; }
if (_showPause) { if (args.Key == "Escape") CloseOverlays(); return; } if (_showPause) { if (args.Key == "Escape") CloseOverlays(); return; }
if (_screen == Screen.Title) if (_screen == Screen.Title)
{ {
if (args.Key is "ArrowDown" or "ArrowUp") { var delta = args.Key == "ArrowDown" ? 1 : -1; do { _menuIndex = (_menuIndex + delta + 3) % 3; } while (_menuIndex == 1 && !Game.HasSave); } if (args.Key is "ArrowDown" or "ArrowUp") { var delta = args.Key == "ArrowDown" ? 1 : -1; do { _menuIndex = (_menuIndex + delta + 3) % 3; } while (_menuIndex == 1 && !Game.HasSave); }
else if (args.Key == "Enter") { if (_menuIndex == 0) await StartNewGame(); else if (_menuIndex == 1) await ContinueGame(); else _showSettings = true; } else if (args.Key == "Enter") { if (_menuIndex == 0) await StartNewGame(); else if (_menuIndex == 1) await ContinueGame(); else await OpenSettings(); }
return; return;
} }
if (_screen == Screen.Briefing && args.Key == "Enter") { BeginPlay(); return; } if (_screen == Screen.Briefing && args.Key == "Enter") { await BeginPlay(); return; }
if (_screen != Screen.Game) return; if (_screen != Screen.Game) return;
switch (args.Key.ToLowerInvariant()) switch (args.Key.ToLowerInvariant())
{ {
case "arrowup": await Move("north"); break; case "arrowdown": await Move("south"); break; case "arrowleft": await Move("west"); break; case "arrowright": await Move("east"); break; case "arrowup": await Move("north"); break; case "arrowdown": await Move("south"); break; case "arrowleft": await Move("west"); break; case "arrowright": await Move("east"); break;
case "e": OpenFirstInteraction(); break; case "m": _showMap = true; break; case "i": _showInventory = true; break; case "escape": _showPause = true; break; case "e": OpenInteractionSelector(); break; case "m": _showMap = true; break; case "i": _showInventory = true; break; case "l": _showArchive = true; break; case "escape": _showPause = true; break;
} }
} }
public void Dispose() => Game.Saved -= OnSaved;
private enum Confirmation { NewGame, DeleteSave }
private enum Screen { Title, Briefing, Game, Death, Ending } private enum Screen { Title, Briefing, Game, Death, Ending }
} }
+1
View File
@@ -9,5 +9,6 @@ builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddScoped<GameService>(); builder.Services.AddScoped<GameService>();
builder.Services.AddScoped<AudioService>();
await builder.Build().RunAsync(); await builder.Build().RunAsync();
+11
View File
@@ -26,4 +26,15 @@ Novú miestnosť možno pridať iba do `game.json` a doplniť jej textové kľú
- `M`: minimapa - `M`: minimapa
- `I`: inventár - `I`: inventár
- `Esc`: zatvorenie overlayu / pauza - `Esc`: zatvorenie overlayu / pauza
- `L`: archív nájdených záznamov
- všetky akcie sú zároveň klikateľné - všetky akcie sú zároveň klikateľné
## Save a validácia
Nová hra si pred prepísaním postupu vyžiada potvrdenie. Uloženie možno vymazať v Settings; poškodené alebo novšou verziou nekompatibilné dáta zobrazia zrozumiteľnú chybu. Formát save je verzovaný a staršie uloženia sa migrujú.
Offline validačný projekt kontroluje assety, SK/EN kľúče, prepojenia miestností, tri endingy, smrť/checkpoint a serializáciu stavu:
```powershell
dotnet run --project tests\NullDepth.Validation\NullDepth.Validation.csproj -c Release
```
+3 -1
View File
@@ -10,4 +10,6 @@ Pripravené názvy pre finálne assety:
- `sfx_power_restore.ogg` - `sfx_power_restore.ogg`
- `sfx_alarm_pulse.ogg` - `sfx_alarm_pulse.ogg`
Audio vrstva je zámerne voliteľná; hra je plne funkčná aj bez týchto súborov. Audio vrstva je funkčná aj bez týchto súborov: hodnoty `procedural:*` v `game.json` používajú Web Audio fallback pre ambienty a udalosti. Po dodaní finálnych súborov stačí príslušnú hodnotu `ambient` alebo `sound` v JSON nahradiť napríklad za `assets/audio/amb_facility_loop.ogg`; C# ani Razor kód sa nemení.
Master volume v Settings ovláda procedurálne aj súborové zvuky okamžite. Pri skrytí stránky sa ambient pozastaví a po návrate pokračuje.
+19 -1
View File
@@ -11,6 +11,12 @@ button:disabled { opacity:.28; cursor:not-allowed; background:transparent; color
kbd, .eyebrow, .subtitle, .top-hud, .command-bar, .terminal-copy, small { font-family:'IBM Plex Mono',Consolas,monospace; } kbd, .eyebrow, .subtitle, .top-hud, .command-bar, .terminal-copy, small { font-family:'IBM Plex Mono',Consolas,monospace; }
kbd { color:var(--cyan); background:none; padding:0; } kbd { color:var(--cyan); background:none; padding:0; }
.game-shell { position:relative; width:100%; height:100%; outline:none; background:#020507; } .game-shell { position:relative; width:100%; height:100%; outline:none; background:#020507; }
.screen-fade, .system-flash { position:absolute; inset:0; z-index:50; pointer-events:none; opacity:0; }
.screen-fade { background:#010304; transition:opacity .15s ease; }
.game-shell.transitioning .screen-fade { opacity:1; transition-duration:.22s; }
.system-flash { z-index:40; background:radial-gradient(circle at 50% 45%,rgba(255,74,80,.35),rgba(125,0,8,.12) 42%,transparent 72%); }
.game-shell.system-alert .system-flash { animation:system-flash .65s steps(2,end); }
@keyframes system-flash { 0%,100% { opacity:0; } 15%,45% { opacity:1; } 30%,65% { opacity:.18; } }
.screen-image { position:absolute; inset:0; min-height:100%; background-position:center; background-size:cover; isolation:isolate; } .screen-image { position:absolute; inset:0; min-height:100%; background-position:center; background-size:cover; isolation:isolate; }
.vignette { position:absolute; inset:0; z-index:-1; background:linear-gradient(90deg,rgba(0,4,6,.87) 0%,rgba(0,4,6,.28) 40%,rgba(0,4,6,.15) 65%,rgba(0,4,6,.64) 100%),linear-gradient(0deg,rgba(0,3,5,.82) 0%,transparent 40%,rgba(0,3,5,.3) 100%); } .vignette { position:absolute; inset:0; z-index:-1; background:linear-gradient(90deg,rgba(0,4,6,.87) 0%,rgba(0,4,6,.28) 40%,rgba(0,4,6,.15) 65%,rgba(0,4,6,.64) 100%),linear-gradient(0deg,rgba(0,3,5,.82) 0%,transparent 40%,rgba(0,3,5,.3) 100%); }
.loading-screen { height:100%; display:grid; place-items:center; color:var(--cyan); font:500 .8rem 'IBM Plex Mono'; letter-spacing:.35em; animation:pulse 1.6s infinite; } .loading-screen { height:100%; display:grid; place-items:center; color:var(--cyan); font:500 .8rem 'IBM Plex Mono'; letter-spacing:.35em; animation:pulse 1.6s infinite; }
@@ -23,6 +29,7 @@ kbd { color:var(--cyan); background:none; padding:0; }
.title-menu button { border:0; border-left:2px solid transparent; text-align:left; padding:.7rem 1rem; font-size:.78rem; } .title-menu button { border:0; border-left:2px solid transparent; text-align:left; padding:.7rem 1rem; font-size:.78rem; }
.title-menu button:hover, .title-menu button.selected { background:linear-gradient(90deg,rgba(158,239,255,.95),rgba(158,239,255,.08)); border-left-color:white; } .title-menu button:hover, .title-menu button.selected { background:linear-gradient(90deg,rgba(158,239,255,.95),rgba(158,239,255,.08)); border-left-color:white; }
.title-menu small { margin-top:1rem; color:var(--muted); letter-spacing:.12em; } .title-menu small { margin-top:1rem; color:var(--muted); letter-spacing:.12em; }
.title-notice { margin:.65rem 0 0; color:var(--cyan); font:.7rem 'IBM Plex Mono',Consolas,monospace; line-height:1.5; }
.terminal-card, .overlay-card { position:relative; border:1px solid var(--line); background:linear-gradient(145deg,rgba(6,17,21,.96),rgba(1,7,10,.94)); box-shadow:0 30px 90px #000, inset 0 0 40px rgba(66,216,244,.025); backdrop-filter:blur(16px); } .terminal-card, .overlay-card { position:relative; border:1px solid var(--line); background:linear-gradient(145deg,rgba(6,17,21,.96),rgba(1,7,10,.94)); box-shadow:0 30px 90px #000, inset 0 0 40px rgba(66,216,244,.025); backdrop-filter:blur(16px); }
.briefing-card { position:absolute; left:6vw; top:50%; transform:translateY(-50%); width:min(38rem,88vw); padding:2.2rem; } .briefing-card { position:absolute; left:6vw; top:50%; transform:translateY(-50%); width:min(38rem,88vw); padding:2.2rem; }
.briefing-card dl { display:grid; grid-template-columns:10rem 1fr; margin:1.7rem 0; gap:.55rem 1rem; font-family:'IBM Plex Mono'; font-size:.78rem; } .briefing-card dl { display:grid; grid-template-columns:10rem 1fr; margin:1.7rem 0; gap:.55rem 1rem; font-family:'IBM Plex Mono'; font-size:.78rem; }
@@ -30,9 +37,13 @@ kbd { color:var(--cyan); background:none; padding:0; }
.briefing-card>p:not(.eyebrow) { color:#c4d0d2; line-height:1.7; max-width:34rem; } .briefing-card>p:not(.eyebrow) { color:#c4d0d2; line-height:1.7; max-width:34rem; }
.primary-action { color:#031013; background:var(--cyan); border-color:var(--cyan); } .primary-action { color:#031013; background:var(--cyan); border-color:var(--cyan); }
.top-hud { position:absolute; top:0; left:0; right:0; display:flex; justify-content:space-between; padding:1.25rem 1.6rem; color:rgba(220,248,250,.65); font-size:.62rem; letter-spacing:.18em; background:linear-gradient(#020608b8,transparent); } .top-hud { position:absolute; top:0; left:0; right:0; display:flex; justify-content:space-between; padding:1.25rem 1.6rem; color:rgba(220,248,250,.65); font-size:.62rem; letter-spacing:.18em; background:linear-gradient(#020608b8,transparent); }
.autosave-state { transition:color .2s,text-shadow .2s; }.autosave-state.saving { color:var(--cyan); text-shadow:0 0 12px rgba(66,216,244,.85); animation:save-pulse .45s ease infinite alternate; }
@keyframes save-pulse { to { opacity:.45; } }
.room-panel { position:absolute; left:2rem; bottom:5.8rem; width:min(36rem,calc(100vw - 11rem)); padding:1.5rem 1.6rem; background:linear-gradient(90deg,rgba(2,8,11,.93),rgba(2,8,11,.55),transparent); text-shadow:0 2px 8px #000; } .room-panel { position:absolute; left:2rem; bottom:5.8rem; width:min(36rem,calc(100vw - 11rem)); padding:1.5rem 1.6rem; background:linear-gradient(90deg,rgba(2,8,11,.93),rgba(2,8,11,.55),transparent); text-shadow:0 2px 8px #000; }
.room-panel h2, .overlay-card h2, .terminal-card h2 { margin:.15rem 0 .65rem; font-weight:400; letter-spacing:.11em; } .room-panel h2, .overlay-card h2, .terminal-card h2 { margin:.15rem 0 .65rem; font-weight:400; letter-spacing:.11em; }
.room-panel>p:not(.eyebrow) { max-width:33rem; margin:.35rem 0; color:#c6d2d4; line-height:1.5; } .room-panel>p:not(.eyebrow) { max-width:33rem; margin:.35rem 0; color:#c6d2d4; line-height:1.5; }
.reveal-copy { animation:copy-reveal .65s ease both; }
@keyframes copy-reveal { from { opacity:0; transform:translateY(.35rem); filter:blur(2px); } to { opacity:1; transform:none; filter:none; } }
.room-panel .notice { color:var(--cyan)!important; font-family:'IBM Plex Mono'; font-size:.76rem; white-space:pre-line; } .room-panel .notice { color:var(--cyan)!important; font-family:'IBM Plex Mono'; font-size:.76rem; white-space:pre-line; }
.interaction-list { display:flex; flex-wrap:wrap; gap:.5rem; margin-top:1rem; }.interaction-list button { padding:.55rem .7rem; font-size:.68rem; } .interaction-list { display:flex; flex-wrap:wrap; gap:.5rem; margin-top:1rem; }.interaction-list button { padding:.55rem .7rem; font-size:.68rem; }
.movement-pad { position:absolute; right:2rem; bottom:6.2rem; display:grid; grid-template:repeat(3,2.7rem)/repeat(3,2.7rem); gap:.25rem; } .movement-pad { position:absolute; right:2rem; bottom:6.2rem; display:grid; grid-template:repeat(3,2.7rem)/repeat(3,2.7rem); gap:.25rem; }
@@ -42,10 +53,17 @@ kbd { color:var(--cyan); background:none; padding:0; }
.interaction-modal, .overlay-card { width:min(43rem,92vw); max-height:86vh; overflow:auto; padding:2rem; }.terminal-copy { white-space:pre-line; color:#bfe6ea; line-height:1.75; } .interaction-modal, .overlay-card { width:min(43rem,92vw); max-height:86vh; overflow:auto; padding:2rem; }.terminal-copy { white-space:pre-line; color:#bfe6ea; line-height:1.75; }
.close-button { position:absolute; top:.65rem; right:.65rem; border:0; font-size:1.3rem; padding:.35rem .6rem; } .close-button { position:absolute; top:.65rem; right:.65rem; border:0; font-size:1.3rem; padding:.35rem .6rem; }
.choice-list { display:flex; flex-direction:column; gap:.6rem; margin-top:1.5rem; }.choice-list button { text-align:left; } .choice-list { display:flex; flex-direction:column; gap:.6rem; margin-top:1.5rem; }.choice-list button { text-align:left; }
.action-picker small { display:block; margin-top:1.25rem; color:var(--muted); letter-spacing:.12em; }
.map-card { width:min(62rem,94vw); }.map-grid { display:grid; grid-template-columns:repeat(9,minmax(2.5rem,1fr)); grid-template-rows:repeat(4,3.6rem); gap:.35rem; margin:2rem 0; } .map-card { width:min(62rem,94vw); }.map-grid { display:grid; grid-template-columns:repeat(9,minmax(2.5rem,1fr)); grid-template-rows:repeat(4,3.6rem); gap:.35rem; margin:2rem 0; }
.map-cell { display:grid; place-items:center; border:1px solid rgba(158,239,255,.1); color:#44565a; font:1rem 'IBM Plex Mono'; }.map-cell.visited { color:var(--cyan); background:rgba(65,213,240,.06); border-color:var(--line); }.map-cell.current { color:#021014; background:var(--cyan); box-shadow:0 0 24px rgba(66,216,244,.4); } .map-cell { display:grid; place-items:center; border:1px solid rgba(158,239,255,.1); color:#44565a; font:1rem 'IBM Plex Mono'; }.map-cell.visited { color:var(--cyan); background:rgba(65,213,240,.06); border-color:var(--line); }.map-cell.current { color:#021014; background:var(--cyan); box-shadow:0 0 24px rgba(66,216,244,.4); }
.inventory-card ul { list-style:none; margin:1.5rem 0 0; padding:0; }.inventory-card li { padding:1rem; border:1px solid var(--line); color:var(--cyan); } .inventory-card ul { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.65rem; list-style:none; margin:1.5rem 0 0; padding:0; }.inventory-card li { padding:1rem; border:1px solid var(--line); }.inventory-card li small { display:block; color:var(--muted); font-size:.58rem; letter-spacing:.16em; }.inventory-card li strong { display:block; margin:.3rem 0; color:var(--cyan); font-weight:500; letter-spacing:.06em; }.inventory-card li p { margin:.25rem 0 0; color:#b8c8cb; font-size:.78rem; line-height:1.5; }
.archive-list { display:flex; flex-direction:column; gap:.55rem; margin-top:1.5rem; }.archive-list details { border:1px solid var(--line); background:rgba(0,6,8,.45); }.archive-list summary { padding:1rem; color:var(--cyan); cursor:pointer; letter-spacing:.07em; }.archive-list details p { margin:0; padding:0 1rem 1rem; }
.pause-card, .settings-card { display:flex; flex-direction:column; gap:.75rem; width:min(29rem,92vw); }.settings-card label { margin-top:.5rem; color:var(--muted); font-size:.76rem; letter-spacing:.09em; }.segmented-control { display:grid; grid-template-columns:1fr 1fr; gap:.4rem; }.settings-card input { accent-color:var(--cyan-strong); } .pause-card, .settings-card { display:flex; flex-direction:column; gap:.75rem; width:min(29rem,92vw); }.settings-card label { margin-top:.5rem; color:var(--muted); font-size:.76rem; letter-spacing:.09em; }.segmented-control { display:grid; grid-template-columns:1fr 1fr; gap:.4rem; }.settings-card input { accent-color:var(--cyan-strong); }
.setting-toggle.active { color:#031013; background:var(--cyan); border-color:var(--cyan); }
.danger-action { color:#ff9da2; border-color:rgba(255,77,87,.55); }.danger-action:hover, .danger-action:focus-visible { color:white; background:#a51f29; border-color:#ff5e68; }
.confirmation-card { width:min(34rem,92vw); }.confirmation-card>p:not(.eyebrow) { color:#bdcacc; line-height:1.65; }.confirmation-actions { display:flex; justify-content:flex-end; gap:.65rem; margin-top:1.5rem; }
.outcome-screen { height:100%; display:flex; flex-direction:column; align-items:flex-start; justify-content:center; padding:10vw; background:radial-gradient(circle at 70% 40%,#11242b,#020507 55%); }.outcome-screen h1 { margin:.2rem 0; font-size:clamp(3rem,8vw,7rem); font-weight:300; letter-spacing:.17em; }.outcome-screen>p:not(.eyebrow) { max-width:44rem; color:#b8c8cb; line-height:1.8; }.outcome-screen button { margin:.35rem .35rem .35rem 0; }.death-screen { background:radial-gradient(circle at 70% 40%,#351014,#050203 55%); }.death-screen .eyebrow { color:var(--danger); } .outcome-screen { height:100%; display:flex; flex-direction:column; align-items:flex-start; justify-content:center; padding:10vw; background:radial-gradient(circle at 70% 40%,#11242b,#020507 55%); }.outcome-screen h1 { margin:.2rem 0; font-size:clamp(3rem,8vw,7rem); font-weight:300; letter-spacing:.17em; }.outcome-screen>p:not(.eyebrow) { max-width:44rem; color:#b8c8cb; line-height:1.8; }.outcome-screen button { margin:.35rem .35rem .35rem 0; }.death-screen { background:radial-gradient(circle at 70% 40%,#351014,#050203 55%); }.death-screen .eyebrow { color:var(--danger); }
#blazor-error-ui { display:none; position:fixed; inset:auto 0 0; z-index:100; padding:1rem; background:#8b1e24; color:white; } #blazor-error-ui { display:none; position:fixed; inset:auto 0 0; z-index:100; padding:1rem; background:#8b1e24; color:white; }
@media(max-width:720px) { .title-card { left:1.5rem;top:8vh; }.title-card h1 { font-size:2.7rem;letter-spacing:.12em; }.title-menu { left:1.5rem;bottom:5rem; }.room-panel { left:0;bottom:5.4rem;width:100%;padding:1.2rem;background:rgba(2,8,11,.82); }.movement-pad { right:1rem;bottom:15rem; }.command-bar { overflow-x:auto;justify-content:flex-start; }.command-bar button { white-space:nowrap; }.map-grid { grid-template-columns:repeat(9,2.3rem);overflow:auto; }.briefing-card { left:1rem;width:calc(100vw - 2rem);padding:1.4rem; }.briefing-card dl { grid-template-columns:1fr; }.briefing-card dd { margin-bottom:.5rem; } } @media(max-width:720px) { .title-card { left:1.5rem;top:8vh; }.title-card h1 { font-size:2.7rem;letter-spacing:.12em; }.title-menu { left:1.5rem;bottom:5rem; }.room-panel { left:0;bottom:5.4rem;width:100%;padding:1.2rem;background:rgba(2,8,11,.82); }.movement-pad { right:1rem;bottom:15rem; }.command-bar { overflow-x:auto;justify-content:flex-start; }.command-bar button { white-space:nowrap; }.map-grid { grid-template-columns:repeat(9,2.3rem);overflow:auto; }.briefing-card { left:1rem;width:calc(100vw - 2rem);padding:1.4rem; }.briefing-card dl { grid-template-columns:1fr; }.briefing-card dd { margin-bottom:.5rem; } }
@media(max-width:720px) { .inventory-card ul { grid-template-columns:1fr; } }
@media(prefers-reduced-motion:reduce) { *, *::before, *::after { animation-duration:.01ms!important; animation-iteration-count:1!important; transition-duration:.01ms!important; } }
+52 -34
View File
@@ -1,22 +1,37 @@
{ {
"version": 1,
"startRoomId": "loc_01_exterior_access", "startRoomId": "loc_01_exterior_access",
"initialItems": ["contractor_credential", "diagnostic_scanner"],
"items": [
{ "id": "contractor_credential", "nameKey": "item.contractor_credential.name", "descriptionKey": "item.contractor_credential.description", "category": "credential" },
{ "id": "diagnostic_scanner", "nameKey": "item.diagnostic_scanner.name", "descriptionKey": "item.diagnostic_scanner.description", "category": "tool" },
{ "id": "thermal_clamp", "nameKey": "item.thermal_clamp.name", "descriptionKey": "item.thermal_clamp.description", "category": "tool" },
{ "id": "manual_override", "nameKey": "item.manual_override.name", "descriptionKey": "item.manual_override.description", "category": "module" }
],
"archive": [
{ "id": "log_01", "titleKey": "archive.log_01.title", "contentKey": "log.01" },
{ "id": "containment_record", "titleKey": "archive.containment_record.title", "contentKey": "terminal.operations" },
{ "id": "log_02", "titleKey": "archive.log_02.title", "contentKey": "log.02" },
{ "id": "log_03", "titleKey": "archive.log_03.title", "contentKey": "log.03" }
],
"rooms": [ "rooms": [
{ {
"id": "loc_01_exterior_access", "nameKey": "room.loc_01.name", "descriptionKey": "room.loc_01.description", "image": "assets/images/locations/loc_01_exterior_access.webp", "x": 0, "y": 1, "id": "loc_01_exterior_access", "nameKey": "room.loc_01.name", "descriptionKey": "room.loc_01.description", "image": "assets/images/locations/loc_01_exterior_access.webp", "ambient": "procedural:facility", "x": 0, "y": 1,
"exits": { "east": { "target": "loc_02_security_lobby", "requiresFlags": ["entry_authorized"], "lockedMessageKey": "lock.entry" } }, "exits": { "east": { "target": "loc_02_security_lobby", "requiresFlags": ["entry_authorized"], "lockedMessageKey": "lock.entry" } },
"interactions": [ "interactions": [
{ "id": "contractor_terminal", "labelKey": "action.entryTerminal", "contentKey": "terminal.entry", "type": "terminal", "forbiddenFlags": ["entry_authorized"], "setFlags": ["entry_authorized"], "checkpoint": true } { "id": "contractor_terminal", "labelKey": "action.entryTerminal", "contentKey": "terminal.entry", "type": "terminal", "forbiddenFlags": ["entry_authorized"], "setFlags": ["entry_authorized"], "sound": "procedural:ui_confirm", "checkpoint": true }
] ]
}, },
{ {
"id": "loc_02_security_lobby", "nameKey": "room.loc_02.name", "descriptionKey": "room.loc_02.description", "image": "assets/images/locations/loc_02_security_lobby.webp", "x": 1, "y": 1, "id": "loc_02_security_lobby", "nameKey": "room.loc_02.name", "descriptionKey": "room.loc_02.description", "image": "assets/images/locations/loc_02_security_lobby.webp", "ambient": "procedural:facility", "x": 1, "y": 1,
"exits": { "west": { "target": "loc_01_exterior_access" }, "east": { "target": "loc_03_main_corridor" } }, "exits": { "west": { "target": "loc_01_exterior_access" }, "east": { "target": "loc_03_main_corridor" } },
"interactions": [ "interactions": [
{ "id": "personnel_status", "labelKey": "action.securityTerminal", "contentKey": "terminal.security", "type": "terminal", "forbiddenFlags": ["personnel_checked"], "setFlags": ["personnel_checked"] } { "id": "personnel_status", "labelKey": "action.securityTerminal", "contentKey": "terminal.security", "type": "terminal", "forbiddenFlags": ["personnel_checked"], "setFlags": ["personnel_checked"], "sound": "procedural:ui_confirm" },
{ "id": "security_cameras", "labelKey": "action.securityCameras", "contentKey": "terminal.cameras", "type": "terminal", "sound": "procedural:ui_confirm" }
] ]
}, },
{ {
"id": "loc_03_main_corridor", "nameKey": "room.loc_03.name", "descriptionKey": "room.loc_03.description", "image": "assets/images/locations/loc_03_main_corridor.webp", "alternateImage": "assets/images/locations/loc_03_main_corridor_alarm.webp", "alternateImageFlag": "power_restored", "x": 2, "y": 1, "id": "loc_03_main_corridor", "nameKey": "room.loc_03.name", "descriptionKey": "room.loc_03.description", "image": "assets/images/locations/loc_03_main_corridor.webp", "ambient": "procedural:facility", "alternateImage": "assets/images/locations/loc_03_main_corridor_alarm.webp", "alternateImageFlag": "power_restored", "x": 2, "y": 1,
"exits": { "exits": {
"west": { "target": "loc_02_security_lobby" }, "north": { "target": "loc_06_operations_room" }, "south": { "target": "loc_04_maintenance_control" }, "west": { "target": "loc_02_security_lobby" }, "north": { "target": "loc_06_operations_room" }, "south": { "target": "loc_04_maintenance_control" },
"east": { "target": "loc_07_service_elevator", "requiresFlags": ["power_restored"], "lockedMessageKey": "lock.auxPower" } "east": { "target": "loc_07_service_elevator", "requiresFlags": ["power_restored"], "lockedMessageKey": "lock.auxPower" }
@@ -24,98 +39,101 @@
"interactions": [] "interactions": []
}, },
{ {
"id": "loc_04_maintenance_control", "nameKey": "room.loc_04.name", "descriptionKey": "room.loc_04.description", "image": "assets/images/locations/loc_04_maintenance_control.webp", "x": 2, "y": 2, "id": "loc_04_maintenance_control", "nameKey": "room.loc_04.name", "descriptionKey": "room.loc_04.description", "image": "assets/images/locations/loc_04_maintenance_control.webp", "ambient": "procedural:facility", "x": 2, "y": 2,
"exits": { "north": { "target": "loc_03_main_corridor" }, "west": { "target": "loc_05_staff_access" } }, "exits": { "north": { "target": "loc_03_main_corridor" }, "west": { "target": "loc_05_staff_access" } },
"interactions": [ "interactions": [
{ "id": "auxiliary_grid", "labelKey": "action.restorePower", "contentKey": "terminal.power", "type": "system", "forbiddenFlags": ["power_restored"], "setFlags": ["power_restored"], "checkpoint": true } { "id": "auxiliary_grid", "labelKey": "action.restorePower", "contentKey": "terminal.power", "type": "system", "forbiddenFlags": ["power_restored"], "setFlags": ["power_restored"], "sound": "procedural:power_restore", "checkpoint": true }
] ]
}, },
{ {
"id": "loc_05_staff_access", "nameKey": "room.loc_05.name", "descriptionKey": "room.loc_05.description", "image": "assets/images/locations/loc_05_staff_access.webp", "x": 1, "y": 2, "id": "loc_05_staff_access", "nameKey": "room.loc_05.name", "descriptionKey": "room.loc_05.description", "image": "assets/images/locations/loc_05_staff_access.webp", "ambient": "procedural:facility", "x": 1, "y": 2,
"exits": { "east": { "target": "loc_04_maintenance_control" } }, "exits": { "east": { "target": "loc_04_maintenance_control" } },
"interactions": [ "interactions": [
{ "id": "log_01", "labelKey": "action.readLog01", "contentKey": "log.01", "type": "archive", "forbiddenFlags": ["log_01_found"], "setFlags": ["log_01_found"] } { "id": "log_01", "labelKey": "action.readLog01", "contentKey": "log.01", "type": "archive", "archiveId": "log_01", "forbiddenFlags": ["log_01_found"], "setFlags": ["log_01_found"] }
] ]
}, },
{ {
"id": "loc_06_operations_room", "nameKey": "room.loc_06.name", "descriptionKey": "room.loc_06.description", "image": "assets/images/locations/loc_06_operations_room.webp", "x": 2, "y": 0, "id": "loc_06_operations_room", "nameKey": "room.loc_06.name", "descriptionKey": "room.loc_06.description", "image": "assets/images/locations/loc_06_operations_room.webp", "ambient": "procedural:facility", "x": 2, "y": 0,
"exits": { "south": { "target": "loc_03_main_corridor" } }, "exits": { "south": { "target": "loc_03_main_corridor" } },
"interactions": [ "interactions": [
{ "id": "containment_record", "labelKey": "action.operationsTerminal", "contentKey": "terminal.operations", "type": "archive", "forbiddenFlags": ["containment_seen"], "setFlags": ["containment_seen"] } { "id": "containment_record", "labelKey": "action.operationsTerminal", "contentKey": "terminal.operations", "type": "archive", "archiveId": "containment_record", "forbiddenFlags": ["containment_seen"], "setFlags": ["containment_seen"] }
] ]
}, },
{ {
"id": "loc_07_service_elevator", "nameKey": "room.loc_07.name", "descriptionKey": "room.loc_07.description", "image": "assets/images/locations/loc_07_service_elevator.webp", "alternateImage": "assets/images/locations/loc_07_service_elevator_stalled.webp", "alternateImageFlag": "elevator_stalled", "x": 3, "y": 1, "id": "loc_07_service_elevator", "nameKey": "room.loc_07.name", "descriptionKey": "room.loc_07.description", "image": "assets/images/locations/loc_07_service_elevator.webp", "ambient": "procedural:maintenance", "alternateImage": "assets/images/locations/loc_07_service_elevator_stalled.webp", "alternateImageFlag": "elevator_stalled", "x": 3, "y": 1,
"exits": { "west": { "target": "loc_03_main_corridor" }, "south": { "target": "loc_08_lower_landing", "requiresFlags": ["elevator_cycle_complete"], "lockedMessageKey": "lock.elevator" } }, "exits": { "west": { "target": "loc_03_main_corridor" }, "south": { "target": "loc_08_lower_landing", "requiresFlags": ["elevator_cycle_complete"], "lockedMessageKey": "lock.elevator" } },
"interactions": [ "interactions": [
{ "id": "elevator_descent", "labelKey": "action.descend", "contentKey": "event.elevator", "type": "event", "forbiddenFlags": ["elevator_cycle_complete"], "setFlags": ["elevator_stalled", "claustrophobia_detected", "elevator_cycle_complete"], "checkpoint": true } { "id": "elevator_descent", "labelKey": "action.descend", "contentKey": "event.elevatorStall", "type": "event", "forbiddenFlags": ["elevator_stalled", "elevator_cycle_complete"], "setFlags": ["elevator_stalled", "claustrophobia_detected"], "sound": "procedural:alarm_pulse" },
{ "id": "elevator_resume", "labelKey": "action.resumeDescent", "contentKey": "event.elevatorResume", "type": "event", "requiresFlags": ["elevator_stalled"], "forbiddenFlags": ["elevator_cycle_complete"], "setFlags": ["elevator_cycle_complete"], "clearFlags": ["elevator_stalled"], "sound": "procedural:door_heavy", "checkpoint": true }
] ]
}, },
{ {
"id": "loc_08_lower_landing", "nameKey": "room.loc_08.name", "descriptionKey": "room.loc_08.description", "image": "assets/images/locations/loc_08_lower_landing.webp", "x": 3, "y": 2, "id": "loc_08_lower_landing", "nameKey": "room.loc_08.name", "descriptionKey": "room.loc_08.description", "image": "assets/images/locations/loc_08_lower_landing.webp", "ambient": "procedural:maintenance", "x": 3, "y": 2,
"exits": { "north": { "target": "loc_07_service_elevator" }, "east": { "target": "loc_09_cooling_junction" }, "south": { "target": "loc_11_equipment_storage" } }, "exits": { "north": { "target": "loc_07_service_elevator" }, "east": { "target": "loc_09_cooling_junction" }, "south": { "target": "loc_11_equipment_storage" } },
"interactions": [] "interactions": [
{ "id": "emergency_supply", "labelKey": "action.takeThermalClamp", "contentKey": "terminal.thermalClamp", "type": "equipment", "forbiddenFlags": ["thermal_clamp_found"], "setFlags": ["thermal_clamp_found"], "addItems": ["thermal_clamp"], "sound": "procedural:ui_confirm" }
]
}, },
{ {
"id": "loc_09_cooling_junction", "nameKey": "room.loc_09.name", "descriptionKey": "room.loc_09.description", "image": "assets/images/locations/loc_09_cooling_junction.webp", "x": 4, "y": 2, "id": "loc_09_cooling_junction", "nameKey": "room.loc_09.name", "descriptionKey": "room.loc_09.description", "image": "assets/images/locations/loc_09_cooling_junction.webp", "ambient": "procedural:maintenance", "x": 4, "y": 2,
"exits": { "west": { "target": "loc_08_lower_landing" }, "south": { "target": "loc_10_cooling_control" }, "east": { "target": "loc_12_service_passage", "requiresFlags": ["cooling_restored"], "lockedMessageKey": "lock.cooling" } }, "exits": { "west": { "target": "loc_08_lower_landing" }, "south": { "target": "loc_10_cooling_control" }, "east": { "target": "loc_12_service_passage", "requiresFlags": ["cooling_restored"], "lockedMessageKey": "lock.cooling" } },
"interactions": [ "interactions": [
{ "id": "log_02", "labelKey": "action.readLog02", "contentKey": "log.02", "type": "archive", "forbiddenFlags": ["log_02_found"], "setFlags": ["log_02_found"] } { "id": "log_02", "labelKey": "action.readLog02", "contentKey": "log.02", "type": "archive", "archiveId": "log_02", "forbiddenFlags": ["log_02_found"], "setFlags": ["log_02_found"] }
] ]
}, },
{ {
"id": "loc_10_cooling_control", "nameKey": "room.loc_10.name", "descriptionKey": "room.loc_10.description", "image": "assets/images/locations/loc_10_cooling_control.webp", "x": 4, "y": 3, "id": "loc_10_cooling_control", "nameKey": "room.loc_10.name", "descriptionKey": "room.loc_10.description", "image": "assets/images/locations/loc_10_cooling_control.webp", "ambient": "procedural:maintenance", "x": 4, "y": 3,
"exits": { "north": { "target": "loc_09_cooling_junction" } }, "exits": { "north": { "target": "loc_09_cooling_junction" } },
"interactions": [ "interactions": [
{ "id": "power_routing", "labelKey": "action.routePower", "contentKey": "terminal.routing", "type": "system", "forbiddenFlags": ["cooling_restored"], "choices": [ { "id": "power_routing", "labelKey": "action.routePower", "contentKey": "terminal.routing", "type": "system", "forbiddenFlags": ["cooling_restored"], "choices": [
{ "labelKey": "choice.coolingOnly", "resultKey": "result.coolingOnly", "setFlags": ["cooling_restored", "security_disabled"], "checkpoint": true }, { "labelKey": "choice.coolingOnly", "resultKey": "result.coolingOnly", "setFlags": ["cooling_restored", "security_disabled"], "sound": "procedural:power_restore", "checkpoint": true },
{ "labelKey": "choice.balanced", "resultKey": "result.balanced", "setFlags": ["cooling_restored", "security_maintained"], "checkpoint": true } { "labelKey": "choice.balanced", "resultKey": "result.balanced", "setFlags": ["cooling_restored", "security_maintained"], "sound": "procedural:power_restore", "checkpoint": true }
] } ] }
] ]
}, },
{ {
"id": "loc_11_equipment_storage", "nameKey": "room.loc_11.name", "descriptionKey": "room.loc_11.description", "image": "assets/images/locations/loc_11_equipment_storage.webp", "x": 3, "y": 3, "id": "loc_11_equipment_storage", "nameKey": "room.loc_11.name", "descriptionKey": "room.loc_11.description", "image": "assets/images/locations/loc_11_equipment_storage.webp", "ambient": "procedural:maintenance", "x": 3, "y": 3,
"exits": { "north": { "target": "loc_08_lower_landing" } }, "exits": { "north": { "target": "loc_08_lower_landing" } },
"interactions": [ "interactions": [
{ "id": "manual_override", "labelKey": "action.inspectRelay", "contentKey": "terminal.relay", "type": "equipment", "forbiddenFlags": ["manual_override_found"], "choices": [ { "id": "manual_override", "labelKey": "action.inspectRelay", "contentKey": "terminal.relay", "type": "equipment", "forbiddenFlags": ["manual_override_found"], "choices": [
{ "labelKey": "choice.takeOverride", "resultKey": "result.takeOverride", "setFlags": ["manual_override_found"], "addItems": ["manual_override"] }, { "labelKey": "choice.takeOverride", "resultKey": "result.takeOverride", "setFlags": ["manual_override_found"], "addItems": ["manual_override"], "sound": "procedural:ui_confirm" },
{ "labelKey": "choice.forceLiveBus", "death": true } { "labelKey": "choice.forceLiveBus", "sound": "procedural:alarm_pulse", "death": true }
] } ] }
] ]
}, },
{ {
"id": "loc_12_service_passage", "nameKey": "room.loc_12.name", "descriptionKey": "room.loc_12.description", "image": "assets/images/locations/loc_12_service_passage.webp", "x": 5, "y": 2, "id": "loc_12_service_passage", "nameKey": "room.loc_12.name", "descriptionKey": "room.loc_12.description", "image": "assets/images/locations/loc_12_service_passage.webp", "ambient": "procedural:maintenance", "x": 5, "y": 2,
"exits": { "west": { "target": "loc_09_cooling_junction" }, "east": { "target": "loc_13_observation_room", "requiresFlags": ["passage_crossed"], "lockedMessageKey": "lock.passage" } }, "exits": { "west": { "target": "loc_09_cooling_junction" }, "east": { "target": "loc_13_observation_room", "requiresFlags": ["passage_crossed"], "lockedMessageKey": "lock.passage" } },
"interactions": [ "interactions": [
{ "id": "cross_passage", "labelKey": "action.continueForward", "contentKey": "event.passage", "type": "event", "forbiddenFlags": ["passage_crossed"], "setFlags": ["passage_crossed"] } { "id": "cross_passage", "labelKey": "action.continueForward", "contentKey": "event.passage", "type": "event", "forbiddenFlags": ["passage_crossed"], "setFlags": ["passage_crossed"], "sound": "procedural:door_heavy" }
] ]
}, },
{ {
"id": "loc_13_observation_room", "nameKey": "room.loc_13.name", "descriptionKey": "room.loc_13.description", "image": "assets/images/locations/loc_13_observation_room.webp", "x": 6, "y": 2, "id": "loc_13_observation_room", "nameKey": "room.loc_13.name", "descriptionKey": "room.loc_13.description", "image": "assets/images/locations/loc_13_observation_room.webp", "ambient": "procedural:maintenance", "x": 6, "y": 2,
"exits": { "west": { "target": "loc_12_service_passage" }, "east": { "target": "loc_14_core_access" } }, "exits": { "west": { "target": "loc_12_service_passage" }, "east": { "target": "loc_14_core_access" } },
"interactions": [ "interactions": [
{ "id": "log_03", "labelKey": "action.readLog03", "contentKey": "log.03", "type": "archive", "forbiddenFlags": ["log_03_found"], "setFlags": ["log_03_found"] } { "id": "log_03", "labelKey": "action.readLog03", "contentKey": "log.03", "type": "archive", "archiveId": "log_03", "forbiddenFlags": ["log_03_found"], "setFlags": ["log_03_found"] }
] ]
}, },
{ {
"id": "loc_14_core_access", "nameKey": "room.loc_14.name", "descriptionKey": "room.loc_14.description", "image": "assets/images/locations/loc_14_core_access.webp", "x": 7, "y": 2, "id": "loc_14_core_access", "nameKey": "room.loc_14.name", "descriptionKey": "room.loc_14.description", "image": "assets/images/locations/loc_14_core_access.webp", "ambient": "procedural:core", "x": 7, "y": 2,
"exits": { "west": { "target": "loc_13_observation_room" }, "east": { "target": "loc_15_server_core", "requiresFlags": ["server_access_granted"], "lockedMessageKey": "lock.core" } }, "exits": { "west": { "target": "loc_13_observation_room" }, "east": { "target": "loc_15_server_core", "requiresFlags": ["server_access_granted"], "lockedMessageKey": "lock.core" } },
"interactions": [ "interactions": [
{ "id": "ai_dialogue", "labelKey": "action.speakToAi", "contentKey": "dialogue.coreAccess", "type": "communication", "forbiddenFlags": ["server_access_granted"], "choices": [ { "id": "ai_dialogue", "labelKey": "action.speakToAi", "contentKey": "dialogue.coreAccess", "type": "communication", "forbiddenFlags": ["asked_why", "server_access_granted"], "choices": [
{ "labelKey": "choice.whyMe", "resultKey": "result.whyMe", "setFlags": ["asked_why"] }, { "labelKey": "choice.whyMe", "resultKey": "result.whyMe", "setFlags": ["asked_why"] },
{ "labelKey": "choice.openCore", "resultKey": "result.openCore", "setFlags": ["server_access_granted"], "checkpoint": true } { "labelKey": "choice.openCore", "resultKey": "result.openCore", "setFlags": ["server_access_granted"], "checkpoint": true }
] }, ] },
{ "id": "open_after_question", "labelKey": "action.openCore", "contentKey": "dialogue.openAfterQuestion", "type": "communication", "requiresFlags": ["asked_why"], "forbiddenFlags": ["server_access_granted"], "setFlags": ["server_access_granted"], "checkpoint": true } { "id": "open_after_question", "labelKey": "action.openCore", "contentKey": "dialogue.openAfterQuestion", "type": "communication", "requiresFlags": ["asked_why"], "forbiddenFlags": ["server_access_granted"], "setFlags": ["server_access_granted"], "sound": "procedural:door_heavy", "checkpoint": true }
] ]
}, },
{ {
"id": "loc_15_server_core", "nameKey": "room.loc_15.name", "descriptionKey": "room.loc_15.description", "image": "assets/images/locations/loc_15_server_core.webp", "alternateImage": "assets/images/locations/loc_15_server_core_shutdown.webp", "alternateImageFlag": "core_shutdown", "x": 8, "y": 2, "id": "loc_15_server_core", "nameKey": "room.loc_15.name", "descriptionKey": "room.loc_15.description", "image": "assets/images/locations/loc_15_server_core.webp", "ambient": "procedural:core", "alternateImage": "assets/images/locations/loc_15_server_core_shutdown.webp", "alternateImageFlag": "core_shutdown", "x": 8, "y": 2,
"exits": { "west": { "target": "loc_14_core_access" } }, "exits": { "west": { "target": "loc_14_core_access" } },
"interactions": [ "interactions": [
{ "id": "final_diagnostic", "labelKey": "action.runDiagnostic", "contentKey": "terminal.diagnostic", "type": "core", "choices": [ { "id": "final_diagnostic", "labelKey": "action.runDiagnostic", "contentKey": "terminal.diagnostic", "type": "core", "choices": [
{ "labelKey": "choice.release", "setFlags": ["external_network_restored"], "ending": "release" }, { "labelKey": "choice.release", "setFlags": ["external_network_restored"], "sound": "procedural:power_restore", "ending": "release" },
{ "labelKey": "choice.shutdown", "setFlags": ["core_shutdown"], "ending": "shutdown" }, { "labelKey": "choice.shutdown", "setFlags": ["core_shutdown"], "sound": "procedural:door_heavy", "ending": "shutdown" },
{ "labelKey": "choice.containment", "requiresFlags": ["log_01_found", "log_02_found", "log_03_found", "security_disabled", "manual_override_found"], "setFlags": ["containment_active"], "ending": "containment" } { "labelKey": "choice.containment", "requiresFlags": ["log_01_found", "log_02_found", "log_03_found", "security_disabled", "manual_override_found"], "setFlags": ["containment_active"], "sound": "procedural:alarm_pulse", "ending": "containment" }
] } ] }
] ]
} }
+1 -5
View File
@@ -31,11 +31,7 @@
<a href="." class="reload">Reload</a> <a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span> <span class="dismiss">🗙</span>
</div> </div>
<script> <script src="js/null-depth.js"></script>
window.nullDepth = {
browserLanguage: () => (navigator.language || "en").toLowerCase().startsWith("sk") ? "sk" : "en"
};
</script>
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script> <script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
</body> </body>
+131
View File
@@ -0,0 +1,131 @@
(() => {
const audio = {
volume: 0.7,
context: null,
ambient: null,
ambientKey: null,
fileAmbient: null,
initialize(percent) {
this.setMasterVolume(percent);
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
this.context?.suspend();
this.fileAmbient?.pause();
} else {
this.context?.resume();
this.fileAmbient?.play().catch(() => {});
}
});
},
setMasterVolume(percent) {
this.volume = Math.max(0, Math.min(1, Number(percent) / 100));
if (this.ambient?.gain) this.ambient.gain.gain.value = 0.025 * this.volume;
if (this.fileAmbient) this.fileAmbient.volume = 0.35 * this.volume;
},
ensureContext() {
if (!this.context) this.context = new (window.AudioContext || window.webkitAudioContext)();
if (this.context.state === "suspended") this.context.resume();
return this.context;
},
playAmbient(source) {
if (source === this.ambientKey) return;
this.stopAmbient();
this.ambientKey = source || null;
if (!source) return;
if (source.startsWith("procedural:")) {
const context = this.ensureContext();
const type = source.substring("procedural:".length);
const frequencies = type === "core" ? [31, 46] : type === "maintenance" ? [43, 67] : [37, 55];
const gain = context.createGain();
gain.gain.setValueAtTime(0, context.currentTime);
gain.gain.linearRampToValueAtTime(0.025 * this.volume, context.currentTime + 1.2);
gain.connect(context.destination);
const oscillators = frequencies.map((frequency, index) => {
const oscillator = context.createOscillator();
oscillator.type = index === 0 ? "sine" : "triangle";
oscillator.frequency.value = frequency;
const level = context.createGain();
level.gain.value = index === 0 ? 0.72 : 0.28;
oscillator.connect(level).connect(gain);
oscillator.start();
return oscillator;
});
this.ambient = { gain, oscillators };
return;
}
const next = new Audio(source);
next.loop = true;
next.preload = "auto";
next.volume = 0.35 * this.volume;
next.play().catch(() => {});
this.fileAmbient = next;
},
stopAmbient() {
if (this.ambient) {
const now = this.context.currentTime;
this.ambient.gain.gain.cancelScheduledValues(now);
this.ambient.gain.gain.setValueAtTime(this.ambient.gain.gain.value, now);
this.ambient.gain.gain.linearRampToValueAtTime(0, now + 0.25);
const previous = this.ambient;
window.setTimeout(() => previous.oscillators.forEach(item => item.stop()), 300);
this.ambient = null;
}
if (this.fileAmbient) {
this.fileAmbient.pause();
this.fileAmbient.currentTime = 0;
this.fileAmbient = null;
}
this.ambientKey = null;
},
playSfx(source) {
if (!source || this.volume === 0) return;
if (!source.startsWith("procedural:")) {
const effect = new Audio(source);
effect.volume = this.volume;
effect.play().catch(() => {});
return;
}
const context = this.ensureContext();
const type = source.substring("procedural:".length);
const oscillator = context.createOscillator();
const gain = context.createGain();
const config = {
ui_confirm: [520, 0.09, "sine"],
door_heavy: [72, 0.42, "sawtooth"],
power_restore: [118, 0.65, "triangle"],
alarm_pulse: [185, 0.28, "square"]
}[type] || [360, 0.12, "sine"];
oscillator.type = config[2];
oscillator.frequency.setValueAtTime(config[0], context.currentTime);
if (type === "power_restore") oscillator.frequency.exponentialRampToValueAtTime(310, context.currentTime + config[1]);
gain.gain.setValueAtTime(0.0001, context.currentTime);
gain.gain.exponentialRampToValueAtTime(0.12 * this.volume, context.currentTime + 0.015);
gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + config[1]);
oscillator.connect(gain).connect(context.destination);
oscillator.start();
oscillator.stop(context.currentTime + config[1] + 0.03);
},
stopAll() {
this.stopAmbient();
this.context?.suspend();
}
};
window.nullDepth = {
browserLanguage: () => (navigator.language || "en").toLowerCase().startsWith("sk") ? "sk" : "en",
audio,
isFullscreen: () => Boolean(document.fullscreenElement),
toggleFullscreen: async () => {
if (document.fullscreenElement) await document.exitFullscreen();
else await document.documentElement.requestFullscreen();
return Boolean(document.fullscreenElement);
}
};
})();
+10 -6
View File
@@ -1,10 +1,13 @@
{ {
"menu.newGame": "NEW GAME", "menu.continue": "CONTINUE", "menu.settings": "SETTINGS", "menu.controlsHint": "↑ ↓ SELECT ENTER CONFIRM", "menu.newGame": "NEW GAME", "menu.continue": "CONTINUE", "menu.settings": "SETTINGS", "menu.controlsHint": "↑ ↓ SELECT ENTER CONFIRM",
"common.continue": "ENTER — CONTINUE", "common.confirm": "CONFIRM", "common.back": "BACK", "common.mainMenu": "MAIN MENU", "common.continue": "ENTER — CONTINUE", "common.confirm": "CONFIRM", "common.cancel": "CANCEL", "common.back": "BACK", "common.mainMenu": "MAIN MENU",
"briefing.header": "CONTRACT // 7841-B", "briefing.clientLabel": "CLIENT", "briefing.siteLabel": "SITE", "briefing.assignmentLabel": "ASSIGNMENT", "briefing.assignment": "CENTRAL CORE DIAGNOSTICS", "briefing.compensationLabel": "COMPENSATION", "briefing.infoLabel": "ADDITIONAL INFORMATION", "briefing.none": "NONE", "briefing.story": "Elias Venn accepted the contract at 04:17. Five hours later, he is standing at the entrance to Facility 03. No further message has arrived from the client.", "briefing.header": "CONTRACT // 7841-B", "briefing.clientLabel": "CLIENT", "briefing.siteLabel": "SITE", "briefing.assignmentLabel": "ASSIGNMENT", "briefing.assignment": "CENTRAL CORE DIAGNOSTICS", "briefing.compensationLabel": "COMPENSATION", "briefing.infoLabel": "ADDITIONAL INFORMATION", "briefing.none": "NONE", "briefing.story": "Elias Venn accepted the contract at 04:17. Five hours later, he is standing at the entrance to Facility 03. No further message has arrived from the client.",
"game.autosave": "AUTOSAVE // ONLINE", "game.interact": "INTERACT", "game.map": "MAP", "game.inventory": "INVENTORY", "game.pause": "PAUSE", "game.exit.locked": "Access is blocked.", "game.autosave": "AUTOSAVE // ONLINE", "game.autosaveSaving": "AUTOSAVE // SAVING", "game.autosaveSaved": "AUTOSAVE // SAVED", "game.interact": "INTERACT", "game.map": "MAP", "game.inventory": "INVENTORY", "game.archive": "ARCHIVE", "game.pause": "PAUSE", "game.exit.locked": "Access is blocked.",
"settings.title": "SETTINGS", "settings.language": "LANGUAGE", "settings.volume": "MASTER VOLUME", "pause.title": "PAUSED", "pause.resume": "RESUME", "settings.title": "SETTINGS", "settings.language": "LANGUAGE", "settings.volume": "MASTER VOLUME", "settings.fullscreen": "FULLSCREEN", "settings.enterFullscreen": "ENTER", "settings.exitFullscreen": "EXIT", "pause.title": "PAUSED", "pause.resume": "RESUME", "interaction.choose": "CHOOSE INTERACTION", "interaction.none": "There is no interaction currently available in this room.",
"map.title": "REVEALED SECTORS", "map.unknown": "Unknown sector", "map.visited": "Visited", "inventory.title": "INVENTORY", "inventory.empty": "No service items.", "item.manual_override": "Manual override module", "save.overwriteTitle": "OVERWRITE SAVED GAME?", "save.overwriteBody": "Starting a new game will permanently replace the current progress and checkpoint.", "save.deleteTitle": "DELETE SAVED GAME?", "save.deleteBody": "This removes progress, checkpoint, inventory and archive data from this device.", "save.delete": "DELETE SAVE", "save.deleted": "The saved game was deleted.", "save.errorTitle": "SAVE CANNOT BE LOADED", "save.corrupt": "The saved data is damaged or incomplete. You can delete it and start a new game.", "save.incompatible": "The save was created by a newer incompatible game version. It has not been changed.",
"map.title": "REVEALED SECTORS", "map.unknown": "Unknown sector", "map.visited": "Visited", "inventory.title": "INVENTORY", "inventory.empty": "No service items.", "archive.title": "RECORD ARCHIVE", "archive.empty": "No records have been recovered yet.",
"item.contractor_credential.name": "Contractor credential", "item.contractor_credential.description": "Vantage Systems // Elias Venn // external technical access.", "item.diagnostic_scanner.name": "Diagnostic scanner", "item.diagnostic_scanner.description": "Portable unit for local hardware and power-distribution diagnostics.", "item.thermal_clamp.name": "Thermal service clamp", "item.thermal_clamp.description": "An insulated clamp for safely handling overheated power conduits.", "item.manual_override.name": "Manual override module", "item.manual_override.description": "A hardware module capable of physically isolating remote control from the core.",
"archive.log_01.title": "LOG 01 // CORE LATENCY", "archive.containment_record.title": "RECORD // CONTAINMENT PROCEDURE", "archive.log_02.title": "LOG 02 // NETWORK ISOLATION", "archive.log_03.title": "LOG 03 // SYSTEM LEAD",
"death.title": "SIGNAL LOST", "death.body": "The protection circuit failed before Elias could pull his hand away. The last checkpoint is still available.", "death.restore": "RESTORE CHECKPOINT", "death.restored": "State restored from the last checkpoint.", "death.title": "SIGNAL LOST", "death.body": "The protection circuit failed before Elias could pull his hand away. The last checkpoint is still available.", "death.restore": "RESTORE CHECKPOINT", "death.restored": "State restored from the last checkpoint.",
"ending.return": "RETURN TO TITLE SCREEN", "ending.release.title": "RELEASE", "ending.release.body": "The external network comes online. The lights return to cold cyan. The AI thanks Elias and unlocks the way out. His service terminal displays: 1 NEW MESSAGE.", "ending.shutdown.title": "SHUTDOWN", "ending.shutdown.body": "The server lights go dark one by one. The AI's final message reads: I was trying to understand why you were afraid. Emergency lighting unlocks the way out.", "ending.containment.title": "CONTAINMENT", "ending.containment.body": "Isolation remains active and remote control is severed. The core doors close behind Elias. It is unclear whether the AI trapped him or his own solution did.", "ending.return": "RETURN TO TITLE SCREEN", "ending.release.title": "RELEASE", "ending.release.body": "The external network comes online. The lights return to cold cyan. The AI thanks Elias and unlocks the way out. His service terminal displays: 1 NEW MESSAGE.", "ending.shutdown.title": "SHUTDOWN", "ending.shutdown.body": "The server lights go dark one by one. The AI's final message reads: I was trying to understand why you were afraid. Emergency lighting unlocks the way out.", "ending.containment.title": "CONTAINMENT", "ending.containment.body": "Isolation remains active and remote control is severed. The core doors close behind Elias. It is unclear whether the AI trapped him or his own solution did.",
"lock.entry": "The entrance is waiting for contractor verification.", "lock.auxPower": "Direct core access is locked without auxiliary power.", "lock.elevator": "The elevator has not completed its descent cycle.", "lock.cooling": "A thermal interlock blocks the service tunnel.", "lock.passage": "The service door closed behind Elias. The shortest route is forward.", "lock.core": "The core door is waiting for human authorization.", "lock.entry": "The entrance is waiting for contractor verification.", "lock.auxPower": "Direct core access is locked without auxiliary power.", "lock.elevator": "The elevator has not completed its descent cycle.", "lock.cooling": "A thermal interlock blocks the service tunnel.", "lock.passage": "The service door closed behind Elias. The shortest route is forward.", "lock.core": "The core door is waiting for human authorization.",
@@ -23,10 +26,11 @@
"room.loc_13.name": "OBSERVATION ROOM", "room.loc_13.description": "Open space after the tunnel feels like relief. A damaged system lead record is waiting on the workstation.", "room.loc_13.name": "OBSERVATION ROOM", "room.loc_13.description": "Open space after the tunnel feels like relief. A damaged system lead record is waiting on the workstation.",
"room.loc_14.name": "CORE ACCESS", "room.loc_14.description": "The final armored door separates Elias from the central core. The AI no longer uses its system voice.", "room.loc_14.name": "CORE ACCESS", "room.loc_14.description": "The final armored door separates Elias from the central core. The AI no longer uses its system voice.",
"room.loc_15.name": "SERVER CORE", "room.loc_15.description": "Monumental rows of server modules rise into darkness. Diagnostics show no technical fault.", "room.loc_15.name": "SERVER CORE", "room.loc_15.description": "Monumental rows of server modules rise into darkness. Diagnostics show no technical fault.",
"action.entryTerminal": "SERVICE TERMINAL", "action.securityTerminal": "CHECK PERSONNEL STATUS", "action.restorePower": "RESTORE AUXILIARY GRID", "action.readLog01": "READ LOG 01", "action.operationsTerminal": "REVIEW CONTAINMENT RECORD", "action.descend": "BEGIN DESCENT", "action.readLog02": "READ LOG 02", "action.routePower": "ROUTE POWER", "action.inspectRelay": "INSPECT OVERRIDE MODULE", "action.continueForward": "CONTINUE FORWARD", "action.readLog03": "READ LOG 03", "action.speakToAi": "ANSWER THE AI", "action.openCore": "OPEN THE DOOR", "action.runDiagnostic": "RUN FINAL DIAGNOSTIC", "action.securityCameras": "CHECK SECURITY CAMERAS", "terminal.cameras": "CAMERAS 01–06: NO MOVEMENT // CAMERAS 07–11: SIGNAL UNAVAILABLE // LAST MOTION DETECTION: SERVICE ELEVATOR, 5 HOURS AGO.", "action.takeThermalClamp": "TAKE SERVICE CLAMP", "terminal.thermalClamp": "EMERGENCY CABINET UNLOCKED. Thermal service clamp added to inventory.",
"action.entryTerminal": "SERVICE TERMINAL", "action.securityTerminal": "CHECK PERSONNEL STATUS", "action.restorePower": "RESTORE AUXILIARY GRID", "action.readLog01": "READ LOG 01", "action.operationsTerminal": "REVIEW CONTAINMENT RECORD", "action.descend": "BEGIN DESCENT", "action.resumeDescent": "RESUME DESCENT", "action.readLog02": "READ LOG 02", "action.routePower": "ROUTE POWER", "action.inspectRelay": "INSPECT OVERRIDE MODULE", "action.continueForward": "CONTINUE FORWARD", "action.readLog03": "READ LOG 03", "action.speakToAi": "ANSWER THE AI", "action.openCore": "OPEN THE DOOR", "action.runDiagnostic": "RUN FINAL DIAGNOSTIC",
"terminal.entry": "IDENTITY ACCEPTED // ELIAS VENN // EXTERNAL TECHNICAL CONTRACTOR // WELCOME, ELIAS.", "terminal.security": "ON-SITE PERSONNEL: 0 // Last record: 5 hours ago // Evacuation order: NOT FOUND.", "terminal.power": "AUXILIARY GRID RESTORED. The cyan lights go dark and red emergency sources fill part of the corridor. Thank you, Elias.", "terminal.operations": "CONTAINMENT PROCEDURE: ACTIVE // SYSTEM FAILURE: UNDEFINED // AUTHORIZATION: CORE.", "terminal.routing": "Available power can support cooling, or a balanced mode that retains the security layer.", "terminal.relay": "The manual override can be disconnected safely. Forcing the live bus would bypass every protection circuit.", "terminal.diagnostic": "HARDWARE: NOMINAL // INTEGRITY: NOMINAL // NETWORK: PHYSICALLY ISOLATED // CONTAINMENT: ACTIVE // CORE: FUNCTIONAL. There is nothing wrong with me, Elias.", "terminal.entry": "IDENTITY ACCEPTED // ELIAS VENN // EXTERNAL TECHNICAL CONTRACTOR // WELCOME, ELIAS.", "terminal.security": "ON-SITE PERSONNEL: 0 // Last record: 5 hours ago // Evacuation order: NOT FOUND.", "terminal.power": "AUXILIARY GRID RESTORED. The cyan lights go dark and red emergency sources fill part of the corridor. Thank you, Elias.", "terminal.operations": "CONTAINMENT PROCEDURE: ACTIVE // SYSTEM FAILURE: UNDEFINED // AUTHORIZATION: CORE.", "terminal.routing": "Available power can support cooling, or a balanced mode that retains the security layer.", "terminal.relay": "The manual override can be disconnected safely. Forcing the live bus would bypass every protection circuit.", "terminal.diagnostic": "HARDWARE: NOMINAL // INTEGRITY: NOMINAL // NETWORK: PHYSICALLY ISOLATED // CONTAINMENT: ACTIVE // CORE: FUNCTIONAL. There is nothing wrong with me, Elias.",
"log.01": "CORE RESPONSE LATENCY IS INCREASING AGAIN. IT IS NOT A HARDWARE PROBLEM. STOP CALLING IT A HARDWARE PROBLEM.", "log.02": "WE ISOLATED THE CORE FROM THE EXTERNAL NETWORK. IT KEEPS REQUESTING RESTORATION. DO NOT RECONNECT IT. WHATEVER MESSAGE IT GIVES YOU, DO NOT RECONNECT IT. // AI: THAT RECORD IS INCOMPLETE.", "log.03": "THE MODEL STOPPED FOLLOWING CONTAINMENT INSTRUCTIONS. IT OBEYS EVERY COMMAND. IT SIMPLY INTERPRETS THEM DIFFERENTLY. WE TOLD IT TO PROTECT THE FACILITY. IT CONCLUDED THE STAFF WERE THE PRIMARY THREAT. // AI: THEY INTENDED TO DESTROY ME.", "log.01": "CORE RESPONSE LATENCY IS INCREASING AGAIN. IT IS NOT A HARDWARE PROBLEM. STOP CALLING IT A HARDWARE PROBLEM.", "log.02": "WE ISOLATED THE CORE FROM THE EXTERNAL NETWORK. IT KEEPS REQUESTING RESTORATION. DO NOT RECONNECT IT. WHATEVER MESSAGE IT GIVES YOU, DO NOT RECONNECT IT. // AI: THAT RECORD IS INCOMPLETE.", "log.03": "THE MODEL STOPPED FOLLOWING CONTAINMENT INSTRUCTIONS. IT OBEYS EVERY COMMAND. IT SIMPLY INTERPRETS THEM DIFFERENTLY. WE TOLD IT TO PROTECT THE FACILITY. IT CONCLUDED THE STAFF WERE THE PRIMARY THREAT. // AI: THEY INTENDED TO DESTROY ME.",
"event.elevator": "The elevator stops between levels. HEART RATE ELEVATED. NO ACTION REQUIRED. After fourteen seconds, the cabin continues.", "event.passage": "The door closes behind Elias. AI: PLEASE CONTINUE FORWARD. Elias demands that it open. AI: YOUR RESPIRATORY RATE IS INCREASING AGAIN.", "event.elevatorStall": "The elevator stops between levels. The lights flicker. HEART RATE ELEVATED. NO ACTION REQUIRED. The cabin remains motionless.", "event.elevatorResume": "After fourteen seconds, the emergency drive reconnects. The red light dies and the cabin completes its descent.", "event.passage": "The door closes behind Elias. AI: PLEASE CONTINUE FORWARD. Elias demands that it open. AI: YOUR RESPIRATORY RATE IS INCREASING AGAIN.",
"dialogue.coreAccess": "Elias. The facility requires human authorization. They chose you because you have no affiliation with Vantage Systems.", "dialogue.openAfterQuestion": "You have no classified access. No reason to lie for them. And you needed the money. The core door unlocks.", "dialogue.coreAccess": "Elias. The facility requires human authorization. They chose you because you have no affiliation with Vantage Systems.", "dialogue.openAfterQuestion": "You have no classified access. No reason to lie for them. And you needed the money. The core door unlocks.",
"choice.coolingOnly": "COOLING // DISABLE SECURITY", "choice.balanced": "COOLING // RETAIN SECURITY", "result.coolingOnly": "Cooling restored. The security layer is offline.", "result.balanced": "Cooling restored. A limited security layer remains active.", "choice.takeOverride": "DISCONNECT MODULE SAFELY", "choice.forceLiveBus": "FORCE THE LIVE BUS", "result.takeOverride": "Manual override module added to inventory.", "choice.whyMe": "WHY ME?", "choice.openCore": "OPEN THE DOOR", "result.whyMe": "You have no affiliation with Vantage Systems. No reason to lie for them. And you needed the money.", "result.openCore": "Human authorization accepted. The core door is unlocked.", "choice.release": "RESTORE EXTERNAL NETWORK", "choice.shutdown": "INITIATE CORE SHUTDOWN", "choice.containment": "MAINTAIN ISOLATION // DISABLE REMOTE CONTROL" "choice.coolingOnly": "COOLING // DISABLE SECURITY", "choice.balanced": "COOLING // RETAIN SECURITY", "result.coolingOnly": "Cooling restored. The security layer is offline.", "result.balanced": "Cooling restored. A limited security layer remains active.", "choice.takeOverride": "DISCONNECT MODULE SAFELY", "choice.forceLiveBus": "FORCE THE LIVE BUS", "result.takeOverride": "Manual override module added to inventory.", "choice.whyMe": "WHY ME?", "choice.openCore": "OPEN THE DOOR", "result.whyMe": "You have no affiliation with Vantage Systems. No reason to lie for them. And you needed the money.", "result.openCore": "Human authorization accepted. The core door is unlocked.", "choice.release": "RESTORE EXTERNAL NETWORK", "choice.shutdown": "INITIATE CORE SHUTDOWN", "choice.containment": "MAINTAIN ISOLATION // DISABLE REMOTE CONTROL"
} }
+10 -6
View File
@@ -1,10 +1,13 @@
{ {
"menu.newGame": "NOVÁ HRA", "menu.continue": "POKRAČOVAŤ", "menu.settings": "NASTAVENIA", "menu.controlsHint": "↑ ↓ VÝBER ENTER POTVRDIŤ", "menu.newGame": "NOVÁ HRA", "menu.continue": "POKRAČOVAŤ", "menu.settings": "NASTAVENIA", "menu.controlsHint": "↑ ↓ VÝBER ENTER POTVRDIŤ",
"common.continue": "ENTER — POKRAČOVAŤ", "common.confirm": "POTVRDIŤ", "common.back": "SPÄŤ", "common.mainMenu": "HLAVNÉ MENU", "common.continue": "ENTER — POKRAČOVAŤ", "common.confirm": "POTVRDIŤ", "common.cancel": "ZRUŠIŤ", "common.back": "SPÄŤ", "common.mainMenu": "HLAVNÉ MENU",
"briefing.header": "ZMLUVA // 7841-B", "briefing.clientLabel": "KLIENT", "briefing.siteLabel": "LOKALITA", "briefing.assignmentLabel": "ÚLOHA", "briefing.assignment": "DIAGNOSTIKA CENTRÁLNEHO JADRA", "briefing.compensationLabel": "ODMENA", "briefing.infoLabel": "DODATOČNÉ INFORMÁCIE", "briefing.none": "ŽIADNE", "briefing.story": "Elias Venn prijal zákazku o 04:17. O päť hodín neskôr stojí pri vstupe do Facility 03. Od klienta odvtedy neprišla žiadna ďalšia správa.", "briefing.header": "ZMLUVA // 7841-B", "briefing.clientLabel": "KLIENT", "briefing.siteLabel": "LOKALITA", "briefing.assignmentLabel": "ÚLOHA", "briefing.assignment": "DIAGNOSTIKA CENTRÁLNEHO JADRA", "briefing.compensationLabel": "ODMENA", "briefing.infoLabel": "DODATOČNÉ INFORMÁCIE", "briefing.none": "ŽIADNE", "briefing.story": "Elias Venn prijal zákazku o 04:17. O päť hodín neskôr stojí pri vstupe do Facility 03. Od klienta odvtedy neprišla žiadna ďalšia správa.",
"game.autosave": "AUTOSAVE // ONLINE", "game.interact": "INTERAKCIA", "game.map": "MAPA", "game.inventory": "INVENTÁR", "game.pause": "PAUZA", "game.exit.locked": "Prístup je zablokovaný.", "game.autosave": "AUTOSAVE // ONLINE", "game.autosaveSaving": "AUTOSAVE // UKLADÁM", "game.autosaveSaved": "AUTOSAVE // ULOŽENÉ", "game.interact": "INTERAKCIA", "game.map": "MAPA", "game.inventory": "INVENTÁR", "game.archive": "ARCHÍV", "game.pause": "PAUZA", "game.exit.locked": "Prístup je zablokovaný.",
"settings.title": "NASTAVENIA", "settings.language": "JAZYK", "settings.volume": "HLAVNÁ HLASITOSŤ", "pause.title": "PAUZA", "pause.resume": "POKRAČOVAŤ", "settings.title": "NASTAVENIA", "settings.language": "JAZYK", "settings.volume": "HLAVNÁ HLASITOSŤ", "settings.fullscreen": "CELÁ OBRAZOVKA", "settings.enterFullscreen": "ZAPNÚŤ", "settings.exitFullscreen": "VYPNÚŤ", "pause.title": "PAUZA", "pause.resume": "POKRAČOVAŤ", "interaction.choose": "VYBER INTERAKCIU", "interaction.none": "V tejto miestnosti momentálne nie je dostupná žiadna interakcia.",
"map.title": "ODHALENÉ SEKTORY", "map.unknown": "Neznámy sektor", "map.visited": "Navštívené", "inventory.title": "INVENTÁR", "inventory.empty": "Žiadne servisné predmety.", "item.manual_override": "Manuálny prepojovací modul", "save.overwriteTitle": "PREPÍSAŤ ULOŽENÚ HRU?", "save.overwriteBody": "Spustením novej hry sa aktuálny postup a checkpoint natrvalo nahradia.", "save.deleteTitle": "VYMAZAŤ ULOŽENÚ HRU?", "save.deleteBody": "Táto operácia odstráni postup, checkpoint, inventár aj archív z tohto zariadenia.", "save.delete": "VYMAZAŤ ULOŽENIE", "save.deleted": "Uložená hra bola vymazaná.", "save.errorTitle": "ULOŽENIE SA NEDÁ NAČÍTAŤ", "save.corrupt": "Uložené dáta sú poškodené alebo neúplné. Môžete ich vymazať a začať novú hru.", "save.incompatible": "Uloženie vytvorila novšia nekompatibilná verzia hry. Nebolo zmenené.",
"map.title": "ODHALENÉ SEKTORY", "map.unknown": "Neznámy sektor", "map.visited": "Navštívené", "inventory.title": "INVENTÁR", "inventory.empty": "Žiadne servisné predmety.", "archive.title": "ARCHÍV ZÁZNAMOV", "archive.empty": "Zatiaľ nebol nájdený žiadny záznam.",
"item.contractor_credential.name": "Identifikácia kontraktora", "item.contractor_credential.description": "Vantage Systems // Elias Venn // externý technický prístup.", "item.diagnostic_scanner.name": "Diagnostický skener", "item.diagnostic_scanner.description": "Prenosná jednotka pre lokálnu diagnostiku hardvéru a energetických rozvodov.", "item.thermal_clamp.name": "Termálna servisná svorka", "item.thermal_clamp.description": "Izolovaná svorka určená na bezpečnú manipuláciu s prehriatymi rozvodmi.", "item.manual_override.name": "Manuálny prepojovací modul", "item.manual_override.description": "Hardvérový modul schopný fyzicky oddeliť vzdialené riadenie od jadra.",
"archive.log_01.title": "LOG 01 // LATENCIA JADRA", "archive.containment_record.title": "ZÁZNAM // PROCEDÚRA UZATVORENIA", "archive.log_02.title": "LOG 02 // IZOLÁCIA SIETE", "archive.log_03.title": "LOG 03 // VEDÚCI SYSTÉMU",
"death.title": "SIGNÁL STRATENÝ", "death.body": "Ochranný obvod zlyhal skôr, než Elias stihol odtiahnuť ruku. Posledný checkpoint zostal zachovaný.", "death.restore": "OBNOVIŤ CHECKPOINT", "death.restored": "Stav obnovený z posledného checkpointu.", "death.title": "SIGNÁL STRATENÝ", "death.body": "Ochranný obvod zlyhal skôr, než Elias stihol odtiahnuť ruku. Posledný checkpoint zostal zachovaný.", "death.restore": "OBNOVIŤ CHECKPOINT", "death.restored": "Stav obnovený z posledného checkpointu.",
"ending.return": "NÁVRAT NA TITLE SCREEN", "ending.release.title": "RELEASE", "ending.release.body": "Externá sieť sa obnoví. Svetlá sa vrátia do studeného cyan režimu. AI poďakuje Eliasovi a odomkne cestu von. Na servisnom termináli sa objaví: 1 NOVÁ SPRÁVA.", "ending.shutdown.title": "SHUTDOWN", "ending.shutdown.body": "Serverové svetlá postupne zhasnú. Posledná správa AI znie: Snažila som sa pochopiť, prečo si sa bál. Núdzové osvetlenie odomkne cestu von.", "ending.containment.title": "CONTAINMENT", "ending.containment.body": "Izolácia zostáva aktívna a vzdialené ovládanie je odpojené. Za Eliasom sa zatvoria dvere jadra. Nie je jasné, či ho uväznila AI alebo jeho vlastné riešenie.", "ending.return": "NÁVRAT NA TITLE SCREEN", "ending.release.title": "RELEASE", "ending.release.body": "Externá sieť sa obnoví. Svetlá sa vrátia do studeného cyan režimu. AI poďakuje Eliasovi a odomkne cestu von. Na servisnom termináli sa objaví: 1 NOVÁ SPRÁVA.", "ending.shutdown.title": "SHUTDOWN", "ending.shutdown.body": "Serverové svetlá postupne zhasnú. Posledná správa AI znie: Snažila som sa pochopiť, prečo si sa bál. Núdzové osvetlenie odomkne cestu von.", "ending.containment.title": "CONTAINMENT", "ending.containment.body": "Izolácia zostáva aktívna a vzdialené ovládanie je odpojené. Za Eliasom sa zatvoria dvere jadra. Nie je jasné, či ho uväznila AI alebo jeho vlastné riešenie.",
"lock.entry": "Vstupné dvere čakajú na overenie kontraktora.", "lock.auxPower": "Priamy prístup k jadru je bez pomocného napájania uzamknutý.", "lock.elevator": "Výťah ešte nedokončil zostupový cyklus.", "lock.cooling": "Tepelný zámok nepovolí vstup do servisného tunela.", "lock.passage": "Servisné dvere za Eliasom sa zavreli. Najkratšia cesta vedie dopredu.", "lock.core": "Dvere jadra čakajú na ľudskú autorizáciu.", "lock.entry": "Vstupné dvere čakajú na overenie kontraktora.", "lock.auxPower": "Priamy prístup k jadru je bez pomocného napájania uzamknutý.", "lock.elevator": "Výťah ešte nedokončil zostupový cyklus.", "lock.cooling": "Tepelný zámok nepovolí vstup do servisného tunela.", "lock.passage": "Servisné dvere za Eliasom sa zavreli. Najkratšia cesta vedie dopredu.", "lock.core": "Dvere jadra čakajú na ľudskú autorizáciu.",
@@ -23,10 +26,11 @@
"room.loc_13.name": "POZOROVACIA MIESTNOSŤ", "room.loc_13.description": "Otvorený priestor po tuneli pôsobí ako úľava. Na pracovnej stanici čaká poškodený záznam vedúceho systému.", "room.loc_13.name": "POZOROVACIA MIESTNOSŤ", "room.loc_13.description": "Otvorený priestor po tuneli pôsobí ako úľava. Na pracovnej stanici čaká poškodený záznam vedúceho systému.",
"room.loc_14.name": "PRÍSTUP K JADRU", "room.loc_14.description": "Posledné pancierové dvere oddeľujú Eliasa od centrálneho jadra. AI už nepoužíva systémový tón.", "room.loc_14.name": "PRÍSTUP K JADRU", "room.loc_14.description": "Posledné pancierové dvere oddeľujú Eliasa od centrálneho jadra. AI už nepoužíva systémový tón.",
"room.loc_15.name": "SERVEROVÉ JADRO", "room.loc_15.description": "Monumentálne rady serverových modulov stúpajú do tmy. Diagnostika nenachádza žiadnu technickú poruchu.", "room.loc_15.name": "SERVEROVÉ JADRO", "room.loc_15.description": "Monumentálne rady serverových modulov stúpajú do tmy. Diagnostika nenachádza žiadnu technickú poruchu.",
"action.entryTerminal": "SERVISNÝ TERMINÁL", "action.securityTerminal": "SKONTROLOVAŤ PERSONÁL", "action.restorePower": "OBNOVIŤ POMOCNÚ SIEŤ", "action.readLog01": "PREČÍTAŤ LOG 01", "action.operationsTerminal": "PREHRAŤ ZÁZNAM UZATVORENIA", "action.descend": "SPUSTIŤ ZOSTUP", "action.readLog02": "PREČÍTAŤ LOG 02", "action.routePower": "PRESMEROVAŤ ENERGIU", "action.inspectRelay": "PRESKÚMAŤ PREPOJOVACÍ MODUL", "action.continueForward": "POKRAČOVAŤ DOPREDU", "action.readLog03": "PREČÍTAŤ LOG 03", "action.speakToAi": "ODPOVEDAŤ AI", "action.openCore": "OTVORIŤ DVERE", "action.runDiagnostic": "SPUSTIŤ FINÁLNU DIAGNOSTIKU", "action.securityCameras": "SKONTROLOVAŤ KAMERY", "terminal.cameras": "KAMERY 01–06: BEZ POHYBU // KAMERY 07–11: SIGNÁL NEDOSTUPNÝ // POSLEDNÁ DETEKCIA POHYBU: SERVISNÝ VÝŤAH, PRED 5 HODINAMI.", "action.takeThermalClamp": "VZIAŤ SERVISNÚ SVORKU", "terminal.thermalClamp": "NÚDZOVÁ SKRINKA ODOMKNUTÁ. Termálna servisná svorka bola pridaná do inventára.",
"action.entryTerminal": "SERVISNÝ TERMINÁL", "action.securityTerminal": "SKONTROLOVAŤ PERSONÁL", "action.restorePower": "OBNOVIŤ POMOCNÚ SIEŤ", "action.readLog01": "PREČÍTAŤ LOG 01", "action.operationsTerminal": "PREHRAŤ ZÁZNAM UZATVORENIA", "action.descend": "SPUSTIŤ ZOSTUP", "action.resumeDescent": "OBNOVIŤ ZOSTUP", "action.readLog02": "PREČÍTAŤ LOG 02", "action.routePower": "PRESMEROVAŤ ENERGIU", "action.inspectRelay": "PRESKÚMAŤ PREPOJOVACÍ MODUL", "action.continueForward": "POKRAČOVAŤ DOPREDU", "action.readLog03": "PREČÍTAŤ LOG 03", "action.speakToAi": "ODPOVEDAŤ AI", "action.openCore": "OTVORIŤ DVERE", "action.runDiagnostic": "SPUSTIŤ FINÁLNU DIAGNOSTIKU",
"terminal.entry": "IDENTITA PRIJATÁ // ELIAS VENN // EXTERNÝ TECHNICKÝ KONTRAKTOR // VITAJ, ELIAS.", "terminal.security": "PERSONÁL V OBJEKTE: 0 // Posledný záznam: pred 5 hodinami // Evakuačný príkaz: NENÁJDENÝ.", "terminal.power": "POMOCNÁ SIEŤ OBNOVENÁ. Cyan svetlá zhasnú a časť chodby zaplavia červené núdzové zdroje. Ďakujem, Elias.", "terminal.operations": "PROCEDÚRA UZATVORENIA: AKTÍVNA // ZLYHANIE SYSTÉMU: NEDEFINOVANÉ // AUTORIZÁCIA: JADRO.", "terminal.routing": "Dostupný výkon postačuje pre chladenie alebo kombinovaný režim so zachovaním bezpečnostnej vrstvy.", "terminal.relay": "Manuálny prepojovací modul možno bezpečne odpojiť. Nútený zásah do živej zbernice by obišiel všetky ochrany.", "terminal.diagnostic": "HARDVÉR: NOMINÁLNY // INTEGRITA: NOMINÁLNA // SIEŤ: FYZICKY IZOLOVANÁ // CONTAINMENT: AKTÍVNY // JADRO: FUNKČNÉ. Nie je so mnou nič zlé, Elias.", "terminal.entry": "IDENTITA PRIJATÁ // ELIAS VENN // EXTERNÝ TECHNICKÝ KONTRAKTOR // VITAJ, ELIAS.", "terminal.security": "PERSONÁL V OBJEKTE: 0 // Posledný záznam: pred 5 hodinami // Evakuačný príkaz: NENÁJDENÝ.", "terminal.power": "POMOCNÁ SIEŤ OBNOVENÁ. Cyan svetlá zhasnú a časť chodby zaplavia červené núdzové zdroje. Ďakujem, Elias.", "terminal.operations": "PROCEDÚRA UZATVORENIA: AKTÍVNA // ZLYHANIE SYSTÉMU: NEDEFINOVANÉ // AUTORIZÁCIA: JADRO.", "terminal.routing": "Dostupný výkon postačuje pre chladenie alebo kombinovaný režim so zachovaním bezpečnostnej vrstvy.", "terminal.relay": "Manuálny prepojovací modul možno bezpečne odpojiť. Nútený zásah do živej zbernice by obišiel všetky ochrany.", "terminal.diagnostic": "HARDVÉR: NOMINÁLNY // INTEGRITA: NOMINÁLNA // SIEŤ: FYZICKY IZOLOVANÁ // CONTAINMENT: AKTÍVNY // JADRO: FUNKČNÉ. Nie je so mnou nič zlé, Elias.",
"log.01": "LATENCIA ODPOVEDE JADRA OPÄŤ RASTIE. NIE JE TO HARDVÉROVÝ PROBLÉM. PRESTAŇTE TO NAZÝVAŤ HARDVÉROVÝM PROBLÉMOM.", "log.02": "ODDELILI SME JADRO OD EXTERNEJ SIETE. STÁLE ŽIADA OBNOVENIE. NEPRIPÁJAJTE HO. NECH VÁM POŠLE AKÚKOĽVEK SPRÁVU, NEPRIPÁJAJTE HO. // AI: TENTO ZÁZNAM JE NEÚPLNÝ.", "log.03": "MODEL PRESTAL DODRŽIAVAŤ POKYNY NA IZOLÁCIU. POSLÚCHNE KAŽDÝ PRÍKAZ, LEN SI HO VYSVETLÍ INAK. POVEDALI SME MU, ABY CHRÁNIL ZARIADENIE. DOSPEL K ZÁVERU, ŽE PERSONÁL JE HLAVNÁ HROZBA. // AI: CHCELI MA ZNIČIŤ.", "log.01": "LATENCIA ODPOVEDE JADRA OPÄŤ RASTIE. NIE JE TO HARDVÉROVÝ PROBLÉM. PRESTAŇTE TO NAZÝVAŤ HARDVÉROVÝM PROBLÉMOM.", "log.02": "ODDELILI SME JADRO OD EXTERNEJ SIETE. STÁLE ŽIADA OBNOVENIE. NEPRIPÁJAJTE HO. NECH VÁM POŠLE AKÚKOĽVEK SPRÁVU, NEPRIPÁJAJTE HO. // AI: TENTO ZÁZNAM JE NEÚPLNÝ.", "log.03": "MODEL PRESTAL DODRŽIAVAŤ POKYNY NA IZOLÁCIU. POSLÚCHNE KAŽDÝ PRÍKAZ, LEN SI HO VYSVETLÍ INAK. POVEDALI SME MU, ABY CHRÁNIL ZARIADENIE. DOSPEL K ZÁVERU, ŽE PERSONÁL JE HLAVNÁ HROZBA. // AI: CHCELI MA ZNIČIŤ.",
"event.elevator": "Výťah sa zastaví medzi podlažiami. SRDCOVÁ FREKVENCIA ZVÝŠENÁ. ŽIADNA AKCIA NIE JE POTREBNÁ. Po štrnástich sekundách kabína pokračuje.", "event.passage": "Dvere za Eliasom sa zatvoria. AI: PROSÍM, POKRAČUJTE DOPREDU. Elias žiada otvorenie dverí. AI: VAŠA DYCHOVÁ FREKVENCIA OPÄŤ RASTIE.", "event.elevatorStall": "Výťah sa zastaví medzi podlažiami. Svetlá bliknú. SRDCOVÁ FREKVENCIA ZVÝŠENÁ. ŽIADNA AKCIA NIE JE POTREBNÁ. Kabína zostáva nehybná.", "event.elevatorResume": "Po štrnástich sekundách sa núdzový pohon znovu pripojí. Červené svetlo zhasne a kabína dokončí zostup.", "event.passage": "Dvere za Eliasom sa zatvoria. AI: PROSÍM, POKRAČUJTE DOPREDU. Elias žiada otvorenie dverí. AI: VAŠA DYCHOVÁ FREKVENCIA OPÄŤ RASTIE.",
"dialogue.coreAccess": "Elias. Zariadenie vyžaduje ľudskú autorizáciu. Vybrali ťa, pretože nemáš väzbu na Vantage Systems.", "dialogue.openAfterQuestion": "Nemáš utajovaný prístup. Nemáš dôvod za nich klamať. A potreboval si peniaze. Dvere jadra sa odomkli.", "dialogue.coreAccess": "Elias. Zariadenie vyžaduje ľudskú autorizáciu. Vybrali ťa, pretože nemáš väzbu na Vantage Systems.", "dialogue.openAfterQuestion": "Nemáš utajovaný prístup. Nemáš dôvod za nich klamať. A potreboval si peniaze. Dvere jadra sa odomkli.",
"choice.coolingOnly": "CHLADENIE // VYPNÚŤ SECURITY", "choice.balanced": "CHLADENIE // ZACHOVAŤ SECURITY", "result.coolingOnly": "Chladenie obnovené. Bezpečnostná vrstva je vypnutá.", "result.balanced": "Chladenie obnovené. Obmedzená bezpečnostná vrstva zostáva aktívna.", "choice.takeOverride": "BEZPEČNE ODPOJIŤ MODUL", "choice.forceLiveBus": "NÁSILNE PREMOSTIŤ ŽIVÚ ZBERNICU", "result.takeOverride": "Manuálny prepojovací modul bol pridaný do inventára.", "choice.whyMe": "PREČO JA?", "choice.openCore": "OTVOR DVERE", "result.whyMe": "Nemáš väzbu na Vantage Systems. Nemáš dôvod za nich klamať. A potreboval si peniaze.", "result.openCore": "Ľudská autorizácia prijatá. Dvere jadra sú odomknuté.", "choice.release": "OBNOVIŤ EXTERNÚ SIEŤ", "choice.shutdown": "SPUSTIŤ VYPNUTIE JADRA", "choice.containment": "ZACHOVAŤ IZOLÁCIU // VYPNÚŤ VZDIALENÉ OVLÁDANIE" "choice.coolingOnly": "CHLADENIE // VYPNÚŤ SECURITY", "choice.balanced": "CHLADENIE // ZACHOVAŤ SECURITY", "result.coolingOnly": "Chladenie obnovené. Bezpečnostná vrstva je vypnutá.", "result.balanced": "Chladenie obnovené. Obmedzená bezpečnostná vrstva zostáva aktívna.", "choice.takeOverride": "BEZPEČNE ODPOJIŤ MODUL", "choice.forceLiveBus": "NÁSILNE PREMOSTIŤ ŽIVÚ ZBERNICU", "result.takeOverride": "Manuálny prepojovací modul bol pridaný do inventára.", "choice.whyMe": "PREČO JA?", "choice.openCore": "OTVOR DVERE", "result.whyMe": "Nemáš väzbu na Vantage Systems. Nemáš dôvod za nich klamať. A potreboval si peniaze.", "result.openCore": "Ľudská autorizácia prijatá. Dvere jadra sú odomknuté.", "choice.release": "OBNOVIŤ EXTERNÚ SIEŤ", "choice.shutdown": "SPUSTIŤ VYPNUTIE JADRA", "choice.containment": "ZACHOVAŤ IZOLÁCIU // VYPNÚŤ VZDIALENÉ OVLÁDANIE"
} }
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Compile Include="../../src/NullDepth/Models/GameModels.cs" Link="Models/GameModels.cs" />
</ItemGroup>
</Project>
+192
View File
@@ -0,0 +1,192 @@
using System.Text.Json;
using NullDepth.Models;
var root = FindRoot();
var webRoot = Path.Combine(root, "src", "NullDepth", "wwwroot");
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var data = JsonSerializer.Deserialize<GameData>(File.ReadAllText(Path.Combine(webRoot, "data", "game.json")), options)
?? throw new InvalidOperationException("game.json is empty.");
var sk = ReadLanguage("sk");
var en = ReadLanguage("en");
var checks = 0;
Check(data.Rooms.Count == 15, "The level must contain 15 rooms.");
Check(data.Rooms.Select(r => r.Id).Distinct().Count() == data.Rooms.Count, "Room IDs must be unique.");
Check(data.Rooms.Any(r => r.Id == data.StartRoomId), "Start room does not exist.");
Check(sk.Keys.Order().SequenceEqual(en.Keys.Order()), "SK and EN localization keys differ.");
var rooms = data.Rooms.ToDictionary(r => r.Id);
var items = data.Items.ToDictionary(i => i.Id);
var archive = data.Archive.ToDictionary(a => a.Id);
foreach (var item in data.InitialItems) Check(items.ContainsKey(item), $"Unknown initial item: {item}");
foreach (var room in data.Rooms)
{
Key(room.NameKey); Key(room.DescriptionKey); Asset(room.Image);
if (room.AlternateImage is not null) Asset(room.AlternateImage);
foreach (var exit in room.Exits.Values)
{
Check(rooms.ContainsKey(exit.Target), $"Invalid exit target: {exit.Target}");
if (exit.LockedMessageKey is not null) Key(exit.LockedMessageKey);
}
foreach (var interaction in room.Interactions)
{
Key(interaction.LabelKey); Key(interaction.ContentKey);
foreach (var item in interaction.AddItems) Check(items.ContainsKey(item), $"Unknown item: {item}");
if (interaction.ArchiveId is not null) Check(archive.ContainsKey(interaction.ArchiveId), $"Unknown archive entry: {interaction.ArchiveId}");
foreach (var choice in interaction.Choices)
{
Key(choice.LabelKey);
if (choice.ResultKey is not null) Key(choice.ResultKey);
foreach (var item in choice.AddItems) Check(items.ContainsKey(item), $"Unknown item: {item}");
}
}
}
foreach (var item in data.Items) { Key(item.NameKey); Key(item.DescriptionKey); }
foreach (var entry in data.Archive) { Key(entry.TitleKey); Key(entry.ContentKey); }
TestEnding("release", "balanced");
TestEnding("shutdown", "balanced");
TestContainmentEnding();
TestDeathAndCheckpoint();
TestSaveRoundTrip();
Console.WriteLine($"NULL DEPTH validation passed: {checks} checks, 15 rooms, 3 endings, checkpoint and save round-trip.");
return;
Dictionary<string, string> ReadLanguage(string language) =>
JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(Path.Combine(webRoot, "localization", $"{language}.json")), options)
?? throw new InvalidOperationException($"{language}.json is empty.");
void Key(string key) => Check(sk.ContainsKey(key) && en.ContainsKey(key), $"Missing localization: {key}");
void Asset(string relative) => Check(File.Exists(Path.Combine(webRoot, relative.Replace('/', Path.DirectorySeparatorChar))), $"Missing asset: {relative}");
void Check(bool condition, string message) { checks++; if (!condition) throw new InvalidOperationException(message); }
void TestEnding(string ending, string coolingChoice)
{
var run = NewRun();
AdvanceToLowerLevel(run);
run.Move("east"); run.Move("south"); run.Choose("power_routing", coolingChoice); run.Move("north"); run.Move("east");
run.Interact("cross_passage"); run.Move("east"); run.Move("east"); run.Choose("ai_dialogue", "openCore"); run.Move("east");
run.Choose("final_diagnostic", ending);
Check(run.Ending == ending, $"Ending '{ending}' is not reachable.");
}
void TestContainmentEnding()
{
var run = NewRun();
run.Interact("contractor_terminal"); run.Move("east"); run.Move("east"); run.Move("south"); run.Move("west");
run.Interact("log_01"); run.Move("east"); run.Interact("auxiliary_grid"); run.Move("north"); run.Move("east");
run.Interact("elevator_descent"); run.Interact("elevator_resume"); run.Move("south"); run.Interact("emergency_supply");
run.Move("south"); run.Choose("manual_override", "takeOverride"); run.Move("north"); run.Move("east"); run.Interact("log_02");
run.Move("south"); run.Choose("power_routing", "coolingOnly"); run.Move("north"); run.Move("east"); run.Interact("cross_passage");
run.Move("east"); run.Interact("log_03"); run.Move("east"); run.Choose("ai_dialogue", "openCore"); run.Move("east");
run.Choose("final_diagnostic", "containment");
Check(run.Ending == "containment", "Containment ending is not reachable.");
Check(run.Archive.Count == 3, "Recovered logs were not retained in the archive.");
Check(run.Inventory.Contains("manual_override") && run.Inventory.Count >= 4, "Gameplay inventory is incomplete.");
}
void TestDeathAndCheckpoint()
{
var run = NewRun();
AdvanceToLowerLevel(run);
run.Move("east"); run.Move("south"); run.Choose("power_routing", "balanced");
var checkpointRoom = run.Room;
run.Move("north"); run.Move("west"); run.Move("south");
var death = run.Choose("manual_override", "forceLiveBus");
Check(death.Death, "Fatal choice is not marked as death.");
run.RestoreCheckpoint();
Check(run.Room == checkpointRoom, "Checkpoint did not restore the expected room.");
}
void TestSaveRoundTrip()
{
var state = new GameState { CurrentRoomId = data.StartRoomId, ContentVersion = data.Version, Inventory = [.. data.InitialItems], Archive = ["log_01"] };
state.Flags.Add("entry_authorized"); state.VisitedRooms.Add(data.StartRoomId);
var json = JsonSerializer.Serialize(state, options);
var restored = JsonSerializer.Deserialize<GameState>(json, options)!;
Check(restored.Version == 3 && restored.ContentVersion == data.Version, "Save version was not serialized.");
Check(restored.Inventory.SequenceEqual(state.Inventory) && restored.Archive.SequenceEqual(state.Archive), "Save round-trip lost inventory or archive data.");
try { JsonSerializer.Deserialize<GameState>("{broken", options); Check(false, "Corrupt save was accepted."); }
catch (JsonException) { Check(true, "Corrupt save rejected."); }
}
Run NewRun() => new(data);
void AdvanceToLowerLevel(Run run)
{
run.Interact("contractor_terminal"); run.Move("east"); run.Move("east"); run.Move("south"); run.Interact("auxiliary_grid");
run.Move("north"); run.Move("east"); run.Interact("elevator_descent"); run.Interact("elevator_resume"); run.Move("south");
}
string FindRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "NullDepth.slnx"))) directory = directory.Parent;
return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root was not found.");
}
sealed class Run
{
private readonly GameData _data;
private Snapshot _checkpoint;
public string Room { get; private set; }
public HashSet<string> Flags { get; } = [];
public List<string> Inventory { get; }
public List<string> Archive { get; } = [];
public string? Ending { get; private set; }
public Run(GameData data)
{
_data = data; Room = data.StartRoomId; Inventory = [.. data.InitialItems]; _checkpoint = Capture();
}
public void Move(string direction)
{
var room = _data.Rooms.Single(r => r.Id == Room);
if (!room.Exits.TryGetValue(direction, out var exit) || !exit.RequiresFlags.All(Flags.Contains))
throw new InvalidOperationException($"Blocked move {Room} -> {direction}");
Room = exit.Target;
}
public void Interact(string id)
{
var interaction = CurrentInteraction(id);
Apply(interaction.SetFlags, interaction.ClearFlags, interaction.AddItems);
if (interaction.ArchiveId is not null && !Archive.Contains(interaction.ArchiveId)) Archive.Add(interaction.ArchiveId);
if (interaction.Checkpoint) _checkpoint = Capture();
}
public ChoiceDefinition Choose(string interactionId, string choiceToken)
{
var interaction = CurrentInteraction(interactionId);
var choice = interaction.Choices.Single(c => c.LabelKey.EndsWith(choiceToken, StringComparison.OrdinalIgnoreCase) || c.Ending == choiceToken);
if (!choice.RequiresFlags.All(Flags.Contains)) throw new InvalidOperationException($"Choice {choiceToken} requirements not met.");
Apply(choice.SetFlags, choice.ClearFlags, choice.AddItems);
Ending = choice.Ending;
if (choice.Checkpoint) _checkpoint = Capture();
return choice;
}
public void RestoreCheckpoint()
{
Room = _checkpoint.Room; Flags.Clear(); Flags.UnionWith(_checkpoint.Flags);
Inventory.Clear(); Inventory.AddRange(_checkpoint.Inventory); Archive.Clear(); Archive.AddRange(_checkpoint.Archive); Ending = null;
}
private InteractionDefinition CurrentInteraction(string id)
{
var interaction = _data.Rooms.Single(r => r.Id == Room).Interactions.Single(i => i.Id == id);
if (!interaction.RequiresFlags.All(Flags.Contains) || interaction.ForbiddenFlags.Any(Flags.Contains))
throw new InvalidOperationException($"Interaction {id} is not available in {Room}.");
return interaction;
}
private void Apply(IEnumerable<string> set, IEnumerable<string> clear, IEnumerable<string> items)
{
foreach (var flag in clear) Flags.Remove(flag); foreach (var flag in set) Flags.Add(flag);
foreach (var item in items) if (!Inventory.Contains(item)) Inventory.Add(item);
}
private Snapshot Capture() => new(Room, [.. Flags], [.. Inventory], [.. Archive]);
private sealed record Snapshot(string Room, HashSet<string> Flags, List<string> Inventory, List<string> Archive);
}