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:
@@ -2,4 +2,7 @@
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/NullDepth/NullDepth.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/NullDepth.Validation/NullDepth.Validation.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -31,4 +31,12 @@ Build celej solution:
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
- HTML/CSS fullscreen UI
|
||||
- JSON obsah bez backendu a databázy
|
||||
- browser localStorage pre save a settings
|
||||
- Web Audio/HTML Audio vrstva s procedurálnym fallbackom
|
||||
- Browser Fullscreen API ovládané zo Settings
|
||||
|
||||
## Princíp
|
||||
|
||||
@@ -24,13 +26,16 @@ Input / kliknutie
|
||||
|
||||
```text
|
||||
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
|
||||
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/localization/sk.json slovenské texty
|
||||
wwwroot/localization/en.json anglické texty
|
||||
wwwroot/assets/images/... title a miestnosti
|
||||
wwwroot/assets/audio/... ambience a SFX sloty
|
||||
tests/NullDepth.Validation offline dátové a gameplay testy
|
||||
```
|
||||
|
||||
## RoomDefinition
|
||||
@@ -42,13 +47,22 @@ Interakcia môže:
|
||||
- vyžadovať alebo zakazovať flagy
|
||||
- nastaviť flagy
|
||||
- pridať predmet
|
||||
- uložiť nájdený záznam do trvalého archívu
|
||||
- vytvoriť checkpoint
|
||||
- ponúknuť dátové voľby
|
||||
- spustiť smrť alebo ending
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace NullDepth.Game;
|
||||
|
||||
public sealed class GameService(HttpClient http, IJSRuntime js)
|
||||
{
|
||||
private const int CurrentSaveVersion = 3;
|
||||
private const string SaveKey = "null-depth.save.v1";
|
||||
private const string SettingsKey = "null-depth.settings.v1";
|
||||
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 PlayerSettings Settings { get; private set; } = new();
|
||||
public bool HasSave { get; private set; }
|
||||
public SaveLoadResult LastLoadResult { get; private set; } = SaveLoadResult.Missing;
|
||||
public event Action? Saved;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
@@ -25,12 +28,16 @@ public sealed class GameService(HttpClient http, IJSRuntime js)
|
||||
Language = await js.InvokeAsync<string>("nullDepth.browserLanguage")
|
||||
};
|
||||
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 RoomDefinition CurrentRoom => Data.Rooms.First(r => r.Id == State.CurrentRoomId);
|
||||
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 async Task SetLanguageAsync(string language, bool persist = true)
|
||||
@@ -42,20 +49,92 @@ public sealed class GameService(HttpClient http, IJSRuntime js)
|
||||
|
||||
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);
|
||||
SetCheckpoint();
|
||||
await SaveAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> ContinueAsync()
|
||||
public async Task<SaveLoadResult> ContinueAsync()
|
||||
{
|
||||
var loaded = await LoadValueAsync<GameState>(SaveKey);
|
||||
if (loaded is null || FindRoom(loaded.CurrentRoomId) is null) return false;
|
||||
var raw = await js.InvokeAsync<string?>("localStorage.getItem", SaveKey);
|
||||
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.VisitedRooms.Add(State.CurrentRoomId);
|
||||
HasSave = true;
|
||||
return true;
|
||||
if (migrated) await SaveAsync();
|
||||
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)
|
||||
@@ -73,14 +152,17 @@ public sealed class GameService(HttpClient http, IJSRuntime js)
|
||||
|
||||
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();
|
||||
await SaveAsync();
|
||||
}
|
||||
|
||||
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();
|
||||
await SaveAsync();
|
||||
}
|
||||
@@ -93,6 +175,8 @@ public sealed class GameService(HttpClient http, IJSRuntime js)
|
||||
State.VisitedRooms = [.. checkpoint.VisitedRooms];
|
||||
State.Flags = [.. checkpoint.Flags];
|
||||
State.Inventory = [.. checkpoint.Inventory];
|
||||
State.Archive = [.. checkpoint.Archive];
|
||||
State.CompletedEnding = null;
|
||||
await SaveAsync();
|
||||
}
|
||||
|
||||
@@ -108,10 +192,21 @@ public sealed class GameService(HttpClient http, IJSRuntime js)
|
||||
State.SavedAt = DateTimeOffset.UtcNow;
|
||||
await StoreValueAsync(SaveKey, State);
|
||||
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 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,
|
||||
VisitedRooms = [.. State.VisitedRooms],
|
||||
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)
|
||||
{
|
||||
var raw = await js.InvokeAsync<string?>("localStorage.getItem", key);
|
||||
|
||||
@@ -2,16 +2,36 @@ namespace NullDepth.Models;
|
||||
|
||||
public sealed class GameData
|
||||
{
|
||||
public int Version { get; set; } = 1;
|
||||
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 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 string Id { get; set; } = "";
|
||||
public string NameKey { get; set; } = "";
|
||||
public string DescriptionKey { get; set; } = "";
|
||||
public string Image { get; set; } = "";
|
||||
public string? Ambient { get; set; }
|
||||
public string? AlternateImage { get; set; }
|
||||
public string? AlternateImageFlag { get; set; }
|
||||
public int X { get; set; }
|
||||
@@ -36,7 +56,10 @@ public sealed class InteractionDefinition
|
||||
public List<string> RequiresFlags { get; set; } = [];
|
||||
public List<string> ForbiddenFlags { get; set; } = [];
|
||||
public List<string> SetFlags { get; set; } = [];
|
||||
public List<string> ClearFlags { get; set; } = [];
|
||||
public List<string> AddItems { get; set; } = [];
|
||||
public string? ArchiveId { get; set; }
|
||||
public string? Sound { get; set; }
|
||||
public bool Checkpoint { get; set; }
|
||||
public List<ChoiceDefinition> Choices { get; set; } = [];
|
||||
}
|
||||
@@ -47,7 +70,9 @@ public sealed class ChoiceDefinition
|
||||
public string? ResultKey { get; set; }
|
||||
public List<string> RequiresFlags { get; set; } = [];
|
||||
public List<string> SetFlags { get; set; } = [];
|
||||
public List<string> ClearFlags { get; set; } = [];
|
||||
public List<string> AddItems { get; set; } = [];
|
||||
public string? Sound { get; set; }
|
||||
public string? Ending { get; set; }
|
||||
public bool Death { get; set; }
|
||||
public bool Checkpoint { get; set; }
|
||||
@@ -55,11 +80,14 @@ public sealed class ChoiceDefinition
|
||||
|
||||
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 HashSet<string> VisitedRooms { get; set; } = [];
|
||||
public HashSet<string> Flags { 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 DateTimeOffset SavedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -70,6 +98,7 @@ public sealed class GameSnapshot
|
||||
public HashSet<string> VisitedRooms { get; set; } = [];
|
||||
public HashSet<string> Flags { get; set; } = [];
|
||||
public List<string> Inventory { get; set; } = [];
|
||||
public List<string> Archive { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class PlayerSettings
|
||||
@@ -77,3 +106,11 @@ public sealed class PlayerSettings
|
||||
public string Language { get; set; } = "en";
|
||||
public int MasterVolume { get; set; } = 70;
|
||||
}
|
||||
|
||||
public enum SaveLoadResult
|
||||
{
|
||||
Success,
|
||||
Missing,
|
||||
Corrupt,
|
||||
Incompatible
|
||||
}
|
||||
|
||||
+182
-28
@@ -1,9 +1,12 @@
|
||||
@page "/"
|
||||
@inject GameService Game
|
||||
@inject AudioService Audio
|
||||
@inject IJSRuntime JS
|
||||
@implements IDisposable
|
||||
|
||||
<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)
|
||||
{
|
||||
<section class="loading-screen"><span>NULL DEPTH // BOOTING</span></section>
|
||||
@@ -16,8 +19,9 @@
|
||||
<nav class="title-menu" aria-label="Main menu">
|
||||
<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(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>
|
||||
@if (!string.IsNullOrWhiteSpace(_titleNotice)) { <p class="title-notice" role="alert">@_titleNotice</p> }
|
||||
</nav>
|
||||
</section>
|
||||
}
|
||||
@@ -44,9 +48,9 @@
|
||||
var room = Game.CurrentRoom;
|
||||
<section class="play-screen screen-image" style="background-image:url('@Game.RoomImage(room)')">
|
||||
<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">
|
||||
<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> }
|
||||
<div class="interaction-list">@foreach (var action in Game.AvailableInteractions()) { <button @onclick="() => OpenInteraction(action)"><kbd>E</kbd> @Game.T(action.LabelKey)</button> }</div>
|
||||
</article>
|
||||
@@ -57,8 +61,8 @@
|
||||
<button class="south" disabled="@(!HasSouth)" @onclick="MoveSouth">↓</button>
|
||||
</nav>
|
||||
<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="() => _showInventory = true"><kbd>I</kbd> @Game.T("game.inventory")</button><button @onclick="() => _showPause = true"><kbd>ESC</kbd> @Game.T("game.pause")</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="() => _showArchive = true"><kbd>L</kbd> @Game.T("game.archive")</button><button @onclick="() => _showPause = true"><kbd>ESC</kbd> @Game.T("game.pause")</button>
|
||||
</footer>
|
||||
</section>
|
||||
}
|
||||
@@ -68,7 +72,23 @@
|
||||
}
|
||||
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)
|
||||
@@ -87,19 +107,39 @@
|
||||
}
|
||||
@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)
|
||||
{
|
||||
<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)
|
||||
{
|
||||
<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 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>
|
||||
}
|
||||
@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>
|
||||
|
||||
@code {
|
||||
@@ -112,15 +152,50 @@
|
||||
private InteractionDefinition? _interaction;
|
||||
private bool _showMap;
|
||||
private bool _showInventory;
|
||||
private bool _showArchive;
|
||||
private bool _showPause;
|
||||
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 OnAfterRenderAsync(bool firstRender) { if (_ready) await _shell.FocusAsync(); }
|
||||
private async Task StartNewGame() { await Game.NewGameAsync(); _screen = Screen.Briefing; _notice = null; }
|
||||
private async Task ContinueGame() { if (await Game.ContinueAsync()) _screen = Screen.Game; }
|
||||
private void BeginPlay() => _screen = Screen.Game;
|
||||
private async Task Move(string direction) { var result = await Game.MoveAsync(direction); if (result is not null) _notice = result; if (result == "") _notice = null; }
|
||||
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 (firstRender && _ready) await _shell.FocusAsync(); }
|
||||
private Task StartNewGame() { if (Game.HasSave) { _confirmation = Confirmation.NewGame; return Task.CompletedTask; } return StartNewGameConfirmed(); }
|
||||
private async Task StartNewGameConfirmed() { _confirmation = null; _titleNotice = null; await Audio.PlaySfxAsync("procedural:ui_confirm"); await Game.NewGameAsync(); _screen = Screen.Briefing; _notice = null; }
|
||||
private async Task ContinueGame()
|
||||
{
|
||||
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 MoveSouth() => Move("south");
|
||||
private Task MoveWest() => Move("west");
|
||||
@@ -129,39 +204,118 @@
|
||||
private bool HasSouth => Game.CurrentRoom.Exits.ContainsKey("south");
|
||||
private bool HasWest => Game.CurrentRoom.Exits.ContainsKey("west");
|
||||
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 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; }
|
||||
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); }
|
||||
private async Task RestoreCheckpoint() { await Game.RestoreCheckpointAsync(); _screen = Screen.Game; _notice = Game.T("death.restored"); }
|
||||
private void ReturnToTitle() { CloseOverlays(); _showSettings = false; _screen = Screen.Title; _menuIndex = 0; }
|
||||
private void OpenInteractionSelector()
|
||||
{
|
||||
var actions = AvailableActions;
|
||||
if (actions.Count == 0) { _notice = Game.T("interaction.none"); return; }
|
||||
if (actions.Count == 1) { OpenInteraction(actions[0]); return; }
|
||||
_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 Task ChangeToSlovak() => ChangeLanguage("sk");
|
||||
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 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 async Task HandleKey(KeyboardEventArgs args)
|
||||
{
|
||||
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 (_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 (_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); }
|
||||
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;
|
||||
}
|
||||
if (_screen == Screen.Briefing && args.Key == "Enter") { BeginPlay(); return; }
|
||||
if (_screen == Screen.Briefing && args.Key == "Enter") { await BeginPlay(); return; }
|
||||
if (_screen != Screen.Game) return;
|
||||
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 "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 }
|
||||
}
|
||||
|
||||
@@ -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<GameService>();
|
||||
builder.Services.AddScoped<AudioService>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
|
||||
@@ -26,4 +26,15 @@ Novú miestnosť možno pridať iba do `game.json` a doplniť jej textové kľú
|
||||
- `M`: minimapa
|
||||
- `I`: inventár
|
||||
- `Esc`: zatvorenie overlayu / pauza
|
||||
- `L`: archív nájdených záznamov
|
||||
- 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
|
||||
```
|
||||
|
||||
@@ -10,4 +10,6 @@ Pripravené názvy pre finálne assety:
|
||||
- `sfx_power_restore.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.
|
||||
|
||||
@@ -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 { color:var(--cyan); background:none; padding:0; }
|
||||
.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; }
|
||||
.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; }
|
||||
@@ -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: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-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); }
|
||||
.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; }
|
||||
@@ -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; }
|
||||
.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); }
|
||||
.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 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; }
|
||||
.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; }
|
||||
.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; }
|
||||
@@ -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; }
|
||||
.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; }
|
||||
.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-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); }
|
||||
.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); }
|
||||
#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) { .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; } }
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
{
|
||||
"version": 1,
|
||||
"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": [
|
||||
{
|
||||
"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" } },
|
||||
"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" } },
|
||||
"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": {
|
||||
"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" }
|
||||
@@ -24,98 +39,101 @@
|
||||
"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" } },
|
||||
"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" } },
|
||||
"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" } },
|
||||
"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" } },
|
||||
"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" } },
|
||||
"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" } },
|
||||
"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" } },
|
||||
"interactions": [
|
||||
{ "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.balanced", "resultKey": "result.balanced", "setFlags": ["cooling_restored", "security_maintained"], "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"], "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" } },
|
||||
"interactions": [
|
||||
{ "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.forceLiveBus", "death": true }
|
||||
{ "labelKey": "choice.takeOverride", "resultKey": "result.takeOverride", "setFlags": ["manual_override_found"], "addItems": ["manual_override"], "sound": "procedural:ui_confirm" },
|
||||
{ "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" } },
|
||||
"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" } },
|
||||
"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" } },
|
||||
"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.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" } },
|
||||
"interactions": [
|
||||
{ "id": "final_diagnostic", "labelKey": "action.runDiagnostic", "contentKey": "terminal.diagnostic", "type": "core", "choices": [
|
||||
{ "labelKey": "choice.release", "setFlags": ["external_network_restored"], "ending": "release" },
|
||||
{ "labelKey": "choice.shutdown", "setFlags": ["core_shutdown"], "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.release", "setFlags": ["external_network_restored"], "sound": "procedural:power_restore", "ending": "release" },
|
||||
{ "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"], "sound": "procedural:alarm_pulse", "ending": "containment" }
|
||||
] }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -31,11 +31,7 @@
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
<script>
|
||||
window.nullDepth = {
|
||||
browserLanguage: () => (navigator.language || "en").toLowerCase().startsWith("sk") ? "sk" : "en"
|
||||
};
|
||||
</script>
|
||||
<script src="js/null-depth.js"></script>
|
||||
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"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.",
|
||||
"game.autosave": "AUTOSAVE // ONLINE", "game.interact": "INTERACT", "game.map": "MAP", "game.inventory": "INVENTORY", "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",
|
||||
"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",
|
||||
"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", "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.",
|
||||
"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.",
|
||||
"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.",
|
||||
@@ -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_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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"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.",
|
||||
"game.autosave": "AUTOSAVE // ONLINE", "game.interact": "INTERAKCIA", "game.map": "MAPA", "game.inventory": "INVENTÁR", "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Ť",
|
||||
"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",
|
||||
"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Ť", "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.",
|
||||
"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.",
|
||||
"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.",
|
||||
@@ -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_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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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>
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user