This commit is contained in:
2026-09-10 11:44:46 +03:00
parent 7a4f9c4ab4
commit 73fa225c6f
1515 changed files with 84590 additions and 15 deletions
+36
View File
@@ -0,0 +1,36 @@
# Godot 4.x
.godot/
.idea/
sdk/
bin/
obj/
#ai
.omo/
.opencode/
gen/
obj/
bin/
*.tres~
# GDScript LSP
.godot/
# Export
*.exe
*.dmg
*.apk
*.aab
*.pck
*.zip
*.log
# OS
.DS_Store
Thumbs.db
# Editor
*.import
*.godot.uid
+50
View File
@@ -0,0 +1,50 @@
namespace Cthangover.Core.Actions.Atomic
{
/// <summary>
/// Dispatched by the scenario DSL to initiate battle. Reads "scene",
/// "enemies", "quest_id" and "new_tag" from dialog variables. The enemies
/// string is a comma-separated list embedded in the scenario script, which
/// BattleServiceImpl.Init splits. Uses ctx.Battle.Init which constructs
/// BattleData with the current background state — this means battle
/// initialization must happen *after* the background has been set by a
/// prior ActionBackground, otherwise the battle gets a null backdrop.
/// </summary>
public class BattleInitAction : IScenarioAction
{
/// <summary>
/// Registered as "battle.init" — the scenario DSL's primary battle
/// initiation command. Reads dialog variables "scene", "enemies",
/// "quest_id", and "new_tag" via ctx.GetParam, then delegates to
/// ctx.Battle.Init which constructs BattleData with the current
/// background and lighting snapshots. Requires both "scene" and
/// "enemies" to be non-empty — returns early with a log warning if
/// either is missing. The enemies string is a comma-separated list
/// embedded in the scenario script.
/// </summary>
public string Name => "battle.init";
/// <summary>
/// Reads battle parameters from dialog variables and initiates
/// battle construction. Must be called after a background has been
/// set (by a prior ActionBackground in the scenario) — otherwise
/// the battle gets a null backdrop. Logs both success and failure
/// cases for scenario debugging.
/// </summary>
public void Run(IActionContext ctx)
{
var sceneRaw = ctx.GetParam("scene");
var enemies = ctx.GetParam("enemies");
var questId = ctx.GetParam("quest_id");
var newTag = ctx.GetParam("new_tag");
if (string.IsNullOrEmpty(sceneRaw) || string.IsNullOrEmpty(enemies))
{
ctx.Log("EVENT", "BattleInitAction: missing 'scene' or 'enemies' variable");
return;
}
ctx.Battle.Init(sceneRaw, enemies, questId, newTag);
ctx.Log("EVENT", $"BattleInitAction: battle started at '{sceneRaw}' with enemies '{enemies}'");
}
}
}
@@ -0,0 +1 @@
uid://dg0so5jaltepi
@@ -0,0 +1,42 @@
using Cthangover.Core.Battle;
namespace Cthangover.Core.Actions.Atomic
{
/// <summary>
/// Selects which battle core (combat ruleset) the next battle will use.
/// The "core" variable maps to a BattleCoreRegistry entry by string ID.
/// Must be called before a battle starts, as BattleServiceImpl.Init reads
/// the active core at initialization time.
/// </summary>
public class BattleSetCoreAction : IScenarioAction
{
/// <summary>
/// Registered as "battle.set_core" — selects the combat ruleset
/// for the next battle encounter. The "core" dialog variable maps
/// to a BattleCoreRegistry entry ID. Must be called before
/// battle.init, as BattleServiceImpl.Init reads the active core at
/// initialization time. If "core" is empty or missing, logs a
/// warning and returns without modifying the registry.
/// </summary>
public string Name => "battle.set_core";
/// <summary>
/// Reads the "core" variable and sets it as the active battle core
/// via BattleCoreRegistry.Instance.SetActive. The core determines
/// combat mechanics (turn order, ability system, etc.) for the
/// next initiated battle.
/// </summary>
public void Run(IActionContext ctx)
{
var core = ctx.GetParam("core");
if (string.IsNullOrEmpty(core))
{
ctx.Log("EVENT", "BattleSetCoreAction: missing 'core' variable");
return;
}
BattleCoreRegistry.Instance.SetActive(core);
ctx.Log("EVENT", $"BattleSetCoreAction: battle core set to '{core}'");
}
}
}
@@ -0,0 +1 @@
uid://qxtnnwcpus6c
@@ -0,0 +1,40 @@
namespace Cthangover.Core.Actions.Atomic
{
/// <summary>
/// Recruits a character to the player's party by character ID string.
/// The "type" variable should match a character ID (e.g. "Marao", "Murakami").
/// Delegates to CharacterData.AddCharacterToParty which handles the actual
/// roster mutation and fallback for unknown IDs.
/// </summary>
public class CharacterAddToPartyAction : IScenarioAction
{
/// <summary>
/// Registered as "character.add_to_party" — adds a character to
/// the player's party roster. The "type" dialog variable is a
/// character ID string. Delegates to CharacterData.AddCharacterToParty
/// which handles persistence. Unknown IDs create a minimal
/// CharacterInfoData with default attributes.
/// </summary>
public string Name => "character.add_to_party";
/// <summary>
/// Reads the "type" variable and recruits the character to the
/// party. Returns early with a log warning if "type" is missing
/// or empty. The character is immediately available in the party
/// roster after this call returns.
/// </summary>
public void Run(IActionContext ctx)
{
var typeRaw = ctx.GetParam("type");
if (string.IsNullOrEmpty(typeRaw))
{
ctx.Log("EVENT", "CharacterAddToPartyAction: missing 'type' variable");
return;
}
ctx.Character.AddToParty(typeRaw);
ctx.Log("EVENT", $"CharacterAddToPartyAction: added '{typeRaw}' to party");
}
}
}
@@ -0,0 +1 @@
uid://rk2oy07s55he
@@ -0,0 +1,39 @@
namespace Cthangover.Core.Actions.Atomic
{
/// <summary>
/// Shows a "character joined" UI notification without actually adding them
/// to the party. Used when the recruitment happens via a different mechanism
/// but the player still needs to see the visual confirmation.
/// </summary>
public class CharacterSendNotificationAction : IScenarioAction
{
/// <summary>
/// Registered as "character.send_notification" — shows a "character
/// joined" UI popup without modifying the party roster. Used when
/// recruitment happens through external means but the player needs
/// visual confirmation, or when re-triggering a notification at a
/// specific story beat.
/// </summary>
public string Name => "character.send_notification";
/// <summary>
/// Reads the "type" variable and triggers the join notification
/// via CharacterData.SendAddNotification. The notification system
/// tracks which characters have already been notified to avoid
/// duplicate popups for the same character.
/// </summary>
public void Run(IActionContext ctx)
{
var typeRaw = ctx.GetParam("type");
if (string.IsNullOrEmpty(typeRaw))
{
ctx.Log("EVENT", "CharacterSendNotificationAction: missing 'type' variable");
return;
}
ctx.Character.SendNotification(typeRaw);
ctx.Log("EVENT", $"CharacterSendNotificationAction: sent notification for '{typeRaw}'");
}
}
}
@@ -0,0 +1 @@
uid://dtl2n2uqy73vh
@@ -0,0 +1,34 @@
namespace Cthangover.Core.Actions.Atomic;
/// <summary>
/// Resets the lighting depth and albedo maps to null via the lighting
/// service. This effectively disables scene-specific lighting masks,
/// reverting to default flat lighting. Used when transitioning between
/// scenes that have different lighting setups or when entering a scene
/// that doesn't use depth-based lighting.
/// </summary>
public class LightingClearMapAction : IScenarioAction
{
/// <summary>
/// Registered as "lighting.clear_map" — resets the depth and albedo
/// lighting maps to null via UiLightController. Both maps are
/// cleared simultaneously because the lighting shader requires
/// either both present or neither — partial clearing produces
/// visual artifacts. Use when transitioning between scenes that
/// have different lighting setups, or when entering a scene that
/// doesn't use depth-based lighting.
/// </summary>
public string Name => "lighting.clear_map";
/// <summary>
/// Delegates to ctx.Lighting.ClearDepthMap which nulls out both
/// depth and albedo textures on the UiLightController singleton.
/// Safe to call when the controller hasn't been initialized
/// (null-conditional access in the implementation).
/// </summary>
public void Run(IActionContext ctx)
{
ctx.Lighting.ClearDepthMap();
ctx.Log("SHADER", "LightingClearMapAction: cleared depth and albedo maps");
}
}
@@ -0,0 +1 @@
uid://dgo3eodt4oqwv
+41
View File
@@ -0,0 +1,41 @@
namespace Cthangover.Core.Actions.Atomic;
/// <summary>
/// Adds a string tag to a quest. Tags are used for conditional logic in
/// scenario scripts — a quest can be checked for tag presence to branch
/// dialog. The tag is added via QuestBase.AddTag, which likely stores it
/// in the quest's save data.
/// </summary>
public class QuestAddTagAction : IScenarioAction
{
/// <summary>
/// Registered as "quest.add_tag" — attaches a string tag to a
/// quest for use in conditional logic. Tags are queried by the
/// scenario DSL's "has_tag" condition to branch dialog or gate
/// quest progression. Both "quest_id" and "tag" dialog variables
/// are required — returns early with a warning if either is
/// missing.
/// </summary>
public string Name => "quest.add_tag";
/// <summary>
/// Reads "quest_id" and "tag" from dialog variables and delegates
/// to ctx.Quests.AddTag. Routes through QuestServiceImpl.TryGet —
/// missing quests are silently skipped. Tags are persisted with
/// save data and survive game restart.
/// </summary>
public void Run(IActionContext ctx)
{
var questId = ctx.GetParam("quest_id");
var tag = ctx.GetParam("tag");
if (string.IsNullOrEmpty(questId) || string.IsNullOrEmpty(tag))
{
ctx.Log("EVENT", "QuestAddTagAction: missing 'quest_id' or 'tag' variable");
return;
}
ctx.Quests.AddTag(questId, tag);
ctx.Log("QUEST", $"QuestAddTagAction: {questId}.AddTag('{tag}')");
}
}
@@ -0,0 +1 @@
uid://b67pq2daykjnq
@@ -0,0 +1,39 @@
namespace Cthangover.Core.Actions.Atomic;
/// <summary>
/// Removes a string tag from a quest. Paired with QuestAddTagAction for
/// reversible quest state. Used in branching scenarios where a tag marks
/// a temporary condition that should be cleared when resolved.
/// </summary>
public class QuestRemoveTagAction : IScenarioAction
{
/// <summary>
/// Registered as "quest.remove_tag" — the inverse of
/// QuestAddTagAction. Removes a tag from a quest to clear
/// temporary conditions (e.g. removing a "witnessed_event" tag
/// after the event has been resolved). Both "quest_id" and "tag"
/// are required.
/// </summary>
public string Name => "quest.remove_tag";
/// <summary>
/// Reads "quest_id" and "tag" from dialog variables and delegates
/// to ctx.Quests.RemoveTag. Removing a non-existent tag is a
/// no-op at the collection level. Missing quests are silently
/// skipped via TryGet.
/// </summary>
public void Run(IActionContext ctx)
{
var questId = ctx.GetParam("quest_id");
var tag = ctx.GetParam("tag");
if (string.IsNullOrEmpty(questId) || string.IsNullOrEmpty(tag))
{
ctx.Log("EVENT", "QuestRemoveTagAction: missing 'quest_id' or 'tag' variable");
return;
}
ctx.Quests.RemoveTag(questId, tag);
ctx.Log("QUEST", $"QuestRemoveTagAction: {questId}.RemoveTag('{tag}')");
}
}
@@ -0,0 +1 @@
uid://dtrfdfrwrmosb
@@ -0,0 +1,41 @@
namespace Cthangover.Core.Actions.Atomic;
/// <summary>
/// Triggers a UI notification for a quest (e.g. "Quest Updated" popup).
/// This is the visual feedback companion to quest state changes — the
/// notification itself doesn't change quest state, it just informs the
/// player that something happened. Call this after SetStatus/SetDataStatus
/// to make the UI react.
/// </summary>
public class QuestSendNotificationAction : IScenarioAction
{
/// <summary>
/// Registered as "quest.send_notification" — triggers a UI popup
/// for a quest (e.g. "Quest Updated" or "New Quest"). This is the
/// visual companion to state changes — call after SetStatus or
/// SetDataStatus so the player sees the update. The notification
/// itself does not modify quest state.
/// </summary>
public string Name => "quest.send_notification";
/// <summary>
/// Reads the "quest_id" variable and delegates to
/// ctx.Quests.SendNotification. The notification content is
/// derived from the quest's current state (title, description,
/// status) — ensure the quest state is updated before calling
/// this. Missing quests are silently skipped via TryGet.
/// </summary>
public void Run(IActionContext ctx)
{
var questId = ctx.GetParam("quest_id");
if (string.IsNullOrEmpty(questId))
{
ctx.Log("EVENT", "QuestSendNotificationAction: missing 'quest_id' variable");
return;
}
ctx.Quests.SendNotification(questId);
ctx.Log("QUEST", $"QuestSendNotificationAction: sent notification for '{questId}'");
}
}
@@ -0,0 +1 @@
uid://bxvyitgnnasgr
@@ -0,0 +1,45 @@
namespace Cthangover.Core.Actions.Atomic;
/// <summary>
/// Sets the numeric progress level within a quest (quest.Data.Status).
/// Unlike QuestSetStatusAction which changes the global quest state
/// (Active/Completed), this handles incremental progress — e.g. "kill 3/5
/// enemies". The "level" param is parsed as int (no fallback) so scenario
/// authors must ensure the value is always a valid integer.
/// </summary>
public class QuestSetDataStatusAction : IScenarioAction
{
/// <summary>
/// Registered as "quest.set_data_status" — sets the numeric
/// progress level within a quest (quest.Data.Status). This is the
/// incremental counter (e.g. "2/5 wolves killed"), separate from
/// the global quest state managed by QuestSetStatusAction. The
/// "level" dialog variable is parsed as int with no fallback —
/// scenario authors must ensure it's always a valid integer.
/// </summary>
public string Name => "quest.set_data_status";
/// <summary>
/// Reads "quest_id" and "level" from dialog variables, parses
/// level as int, and delegates to ctx.Quests.SetDataStatus. No
/// bounds checking on the level value — the quest's data object
/// is responsible for validating the range. Missing quests are
/// silently skipped. Returns early with a warning if either
/// variable is missing.
/// </summary>
public void Run(IActionContext ctx)
{
var questId = ctx.GetParam("quest_id");
var levelRaw = ctx.GetParam("level");
if (string.IsNullOrEmpty(questId) || string.IsNullOrEmpty(levelRaw))
{
ctx.Log("EVENT", "QuestSetDataStatusAction: missing 'quest_id' or 'level' variable");
return;
}
var level = int.Parse(levelRaw);
ctx.Quests.SetDataStatus(questId, level);
ctx.Log("QUEST", $"QuestSetDataStatusAction: {questId}.Data.Status = {level}");
}
}
@@ -0,0 +1 @@
uid://bdjd4cjbplu76
@@ -0,0 +1,41 @@
namespace Cthangover.Core.Actions.Atomic;
/// <summary>
/// Sets the global state of a quest (e.g. "Active", "Completed", "Failed").
/// The "status" param is parsed via Enums&lt;QuestStatus&gt;.Parse which is
/// case-sensitive — invalid status strings are caught by QuestServiceImpl's
/// TryGet pattern and logged as errors rather than crashing the dialog.
/// </summary>
public class QuestSetStatusAction : IScenarioAction
{
/// <summary>
/// Registered as "quest.set_status" — sets the global lifecycle
/// state of a quest (Active, Completed, Failed, etc.). The
/// "status" dialog variable is parsed via
/// Enums&lt;QuestStatus&gt;.Parse — case-sensitive and must match
/// a QuestStatus enum value exactly. Invalid status strings are
/// caught by QuestServiceImpl and logged as errors without
/// crashing the dialog.
/// </summary>
public string Name => "quest.set_status";
/// <summary>
/// Reads "quest_id" and "status" from dialog variables and
/// delegates to ctx.Quests.SetStatus. Missing quests are silently
/// skipped. Invalid status values are logged and ignored.
/// </summary>
public void Run(IActionContext ctx)
{
var questId = ctx.GetParam("quest_id");
var statusRaw = ctx.GetParam("status");
if (string.IsNullOrEmpty(questId) || string.IsNullOrEmpty(statusRaw))
{
ctx.Log("EVENT", "QuestSetStatusAction: missing 'quest_id' or 'status' variable");
return;
}
ctx.Quests.SetStatus(questId, statusRaw);
ctx.Log("QUEST", $"QuestSetStatusAction: {questId}.Status = {statusRaw}");
}
}
@@ -0,0 +1 @@
uid://epxcygb10jfr
@@ -0,0 +1,43 @@
namespace Cthangover.Core.Actions.Atomic;
/// <summary>
/// Instantiates a PackedScene into the current scene tree at runtime.
/// The "path" is a Godot resource path (e.g. "res://scenes/SomeObject.tscn"),
/// "name" sets the node's Name property. The instantiated node becomes a
/// child of SceneContextNode.Instance (the current scene root), making it
/// visible immediately.
/// </summary>
public class SceneInstantiateAction : IScenarioAction
{
/// <summary>
/// Registered as "scene.instantiate" — loads a PackedScene from a
/// Godot resource path and inserts it into the current scene tree
/// at runtime. The "path" variable is a Godot resource path (e.g.
/// "res://scenes/SomeObject.tscn"), "name" sets the node's Name
/// property. The node becomes a child of SceneContextNode.Instance
/// and is visible immediately. Both variables are required.
/// </summary>
public string Name => "scene.instantiate";
/// <summary>
/// Reads "path" and "name" from dialog variables and delegates to
/// ctx.Scene.Instantiate. Uses GD.Load&lt;PackedScene&gt;
/// internally — if the resource fails to load (wrong path), an
/// error is logged but the dialog continues. Returns early with a
/// warning if either variable is missing.
/// </summary>
public void Run(IActionContext ctx)
{
var path = ctx.GetParam("path");
var name = ctx.GetParam("name");
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(name))
{
ctx.Log("EVENT", "SceneInstantiateAction: missing 'path' or 'name' variable");
return;
}
ctx.Scene.Instantiate(path, name);
ctx.Log("SCENE", $"SceneInstantiateAction: instantiated '{path}' as '{name}'");
}
}
@@ -0,0 +1 @@
uid://bdgw0vo0oex2n
@@ -0,0 +1,39 @@
namespace Cthangover.Core.Actions.Atomic;
/// <summary>
/// Removes a named child from the scene tree. Before RemoveChild+QueueFree,
/// it calls SceneContextNode.RemoveEventObject to notify the event system
/// that the object is being destroyed — this prevents stale references in
/// the event chain. The "name" must match the node's Name property exactly.
/// </summary>
public class SceneRemoveObjectAction : IScenarioAction
{
/// <summary>
/// Registered as "scene.remove_object" — removes a named child
/// node from the scene tree. Before freeing the node, it notifies
/// the event system via SceneContextNode.RemoveEventObject to
/// clean up subscriptions. The "name" variable must match the
/// node's Name property exactly — partial matches won't work.
/// </summary>
public string Name => "scene.remove_object";
/// <summary>
/// Reads the "name" variable and delegates to ctx.Scene.Remove.
/// Safe to call on non-existent nodes — the implementation
/// silently returns if the named child is not found. Returns
/// early with a warning if "name" is missing.
/// </summary>
public void Run(IActionContext ctx)
{
var name = ctx.GetParam("name");
if (string.IsNullOrEmpty(name))
{
ctx.Log("EVENT", "SceneRemoveObjectAction: missing 'name' variable");
return;
}
ctx.Scene.Remove(name);
ctx.Log("SCENE", $"SceneRemoveObjectAction: removed '{name}'");
}
}
@@ -0,0 +1 @@
uid://c2rdu3p58rucu
+54
View File
@@ -0,0 +1,54 @@
using Godot;
namespace Cthangover.Core.Actions.Atomic;
/// <summary>
/// Toggles the visibility of any Control node in the scene by name.
/// Uses ctx.Scene.Find for recursive tree search — panels can be nested
/// arbitrarily deep. Unlike Show/Hide methods that use Widget lifecycle,
/// this toggles Godot's native Visible property directly, so it works on
/// non-Widget controls as well. The toggle is unconditional: if the panel
/// doesn't exist, it logs an error rather than creating it.
/// </summary>
public class TogglePanelAction : IScenarioAction
{
/// <summary>
/// Registered as "ui.toggle_panel" — toggles the visibility of
/// any Control-derived node in the scene by name. Uses
/// ctx.Scene.Find for recursive tree search, so panels can be
/// nested arbitrarily deep. Toggles Godot's native Visible
/// property directly (not Widget lifecycle methods), so it works
/// on non-Widget controls. The toggle is unconditional — if the
/// panel doesn't exist, it logs a warning rather than creating it.
/// </summary>
public string Name => "ui.toggle_panel";
/// <summary>
/// Reads the "name" variable, locates the Control node via
/// recursive scene tree search, and flips its Visible property.
/// Logs both the toggle result (new visibility state) and the
/// not-found case. Returns early with a warning if "name" is
/// missing.
/// </summary>
public void Run(IActionContext ctx)
{
var name = ctx.GetParam("name");
if (string.IsNullOrEmpty(name))
{
ctx.Log("EVENT", "TogglePanelAction: missing 'name' variable");
return;
}
var panel = ctx.Scene.Find<Control>(name);
if (panel != null)
{
panel.Visible = !panel.Visible;
ctx.Log("WIDGET", $"TogglePanelAction: toggled '{name}' visibility to {panel.Visible}");
}
else
{
ctx.Log("EVENT", $"TogglePanelAction: panel '{name}' not found");
}
}
}
@@ -0,0 +1 @@
uid://ckiit3en7op5b
+83
View File
@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Battle;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Quests;
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Core.UI.Lights;
using Cthangover.Core.Utils;
namespace Cthangover.Core.Actions
{
/// <summary>
/// Constructs BattleData from scenario parameters and stores it in
/// GameData.Instance.Runtime.BattleData. Captures the current background
/// texture (via BackgroundFactory using SceneContextNode.LastBackgroundID)
/// so the battle scene has the correct visual context. Also captures the
/// depth/albedo maps from UiLightController for lighting consistency.
/// If a questId is provided, binds the quest to the battle and optionally
/// sends a notification for the new tag. Active battle core is resolved
/// from BattleCoreRegistry — wrapped in try/catch because the registry
/// may not be initialized when called from non-battle contexts.
/// </summary>
internal class BattleServiceImpl : IBattleService
{
/// <summary>
/// Constructs BattleData from scenario parameters and stores it in
/// GameData.Instance.Runtime.BattleData. Captures the current
/// background texture via BackgroundFactory using
/// SceneContextNode.LastBackgroundID — the battle must be initiated
/// after the background has been set by a prior ActionBackground,
/// otherwise the backdrop is null. Also snapshots the depth/albedo
/// lighting maps from UiLightController for visual consistency
/// across the scene-to-battle transition. If questId is provided,
/// the quest is bound to the battle and an optional newTag triggers
/// a quest notification. The active battle core (combat ruleset)
/// is resolved from BattleCoreRegistry — wrapped in try/catch
/// because the registry may not be initialized when this is called
/// from non-battle contexts (e.g. preloading).
/// </summary>
public void Init(string sceneType, string enemies, string questId = null, string newTag = null)
{
var enemyList = enemies.Split(',');
var background = BackgroundFactory.Instance.Get(SceneContextNode.LastBackgroundID);
GameLogger.Log("BATTLE", $"BattleServiceImpl.Init: lastBgId='{SceneContextNode.LastBackgroundID}', bgTexture={(background != null ? "loaded" : "NULL")}", background == null ? LogLevel.Error : LogLevel.Debug);
var data = BattleData.InitBattle(background, sceneType, enemyList);
data.DepthMap = UiLightController.Instance?.CurrentDepthMap;
data.AlbedoMap = UiLightController.Instance?.CurrentAlbedoMap;
GameLogger.Log("BATTLE", $"BattleServiceImpl.Init: depthMap from LightsCtrl = {(data.DepthMap != null ? "captured" : "NULL")}, albedoMap = {(data.AlbedoMap != null ? "captured" : "NULL")}", data.DepthMap == null && data.DepthMap == null ? LogLevel.Error : LogLevel.Debug);
try
{
data.ActiveBattleCore = BattleCoreRegistry.Instance.GetActive()?.Id;
}
catch(Exception ex)
{
GameLogger.Log("BATTLE", $"BattleServiceImpl.Init: {ex.Message}\n{ex.StackTrace}", LogLevel.Error);
}
if (!string.IsNullOrEmpty(questId))
{
try
{
var quest = QuestFactory.Instance.Get(questId);
data.Quest = quest;
if (!string.IsNullOrEmpty(newTag))
{
data.NewTag = newTag;
quest.SendNotification();
}
}
catch (KeyNotFoundException)
{
GameLogger.Log("BATTLE", $"BattleInit: quest '{questId}' not found, battle will proceed without quest", LogLevel.Error);
}
}
GameData.Instance.Runtime.BattleData = data;
}
}
}
+1
View File
@@ -0,0 +1 @@
uid://dj6iia51f8aj2
+39
View File
@@ -0,0 +1,39 @@
using Cthangover.Core.Settings;
namespace Cthangover.Core.Actions
{
/// <summary>
/// Delegates character operations to GameData.Instance.Runtime.CharacterData.
/// The type string is a character ID — it is passed directly to CharacterData
/// without parsing. Malformed or missing IDs are handled inside CharacterData
/// (null-character fallback for unknown IDs).
/// </summary>
internal class CharacterServiceImpl : ICharacterService
{
/// <summary>
/// Passes the type string directly to CharacterData.AddCharacterToParty.
/// The character ID is looked up via CharacterFactory; if no template
/// exists for this ID, a minimal CharacterInfoData is created with
/// default attributes. The character is added to the runtime party and
/// persists through the save system.
/// </summary>
public void AddToParty(string type)
{
var characterData = GameData.Instance.Runtime.CharacterData;
characterData.AddCharacterToParty(type);
}
/// <summary>
/// Passes the type string directly to CharacterData.SendAddNotification.
/// Unlike AddToParty, this only triggers the UI popup without modifying
/// the party roster. Useful when a character was recruited earlier (or
/// externally) but the notification needs to appear at a specific story
/// beat.
/// </summary>
public void SendNotification(string type)
{
var characterData = GameData.Instance.Runtime.CharacterData;
characterData.SendAddNotification(type);
}
}
}
+1
View File
@@ -0,0 +1 @@
uid://dpvdu87dccvty
+31
View File
@@ -0,0 +1,31 @@
using Cthangover.Core.Events;
using Godot;
namespace Cthangover.Core.Actions;
[GlobalClass]
public partial class GDActionContext : RefCounted
{
internal GDActionContextCore Core = new();
public GDQuestService Quests => Core.Quests;
public GDCharacterService Character => Core.Character;
public GDBattleService Battle => Core.Battle;
public GDLightingService Lighting => Core.Lighting;
public GDSceneNodeService Scene => Core.Scene;
public Items.GDInventoryService Inventory => Core.Inventory;
public Mods.GDModRegistryService ModRegistry => Core.ModRegistry;
public string GetParam(string name) => Core.GetParam(name);
public void Log(string category, string message) => Core.Log(category, message);
public void LogWarning(string category, string message) => Core.LogWarning(category, message);
/// <summary>
/// GDScript: ctx.PublishEvent("cooking_meal_ready", {"dish": "wolf_stew"})
/// Publishes an event to the global bus for inter-mod communication.
/// </summary>
public void PublishEvent(string eventKey, Godot.Collections.Dictionary data)
{
ModEventBus.Instance?.Publish(eventKey, data);
}
}
+1
View File
@@ -0,0 +1 @@
uid://7nan7w2tiwqr
+34
View File
@@ -0,0 +1,34 @@
namespace Cthangover.Core.Actions;
public class GDActionContextCore
{
public IActionContext Inner
{
get => _inner;
set
{
_inner = value;
Quests.Inner = value?.Quests;
Character.Inner = value?.Character;
Battle.Inner = value?.Battle;
Lighting.Inner = value?.Lighting;
Scene.Inner = value?.Scene;
Inventory.Inner = value?.Inventory;
ModRegistry.Inner = value?.ModRegistry;
}
}
public string GetParam(string name) => _inner?.GetParam(name);
public void Log(string category, string message) => _inner?.Log(category, message);
public void LogWarning(string category, string message) => _inner?.LogWarning(category, message);
public readonly GDQuestService Quests = new();
public readonly GDCharacterService Character = new();
public readonly GDBattleService Battle = new();
public readonly GDLightingService Lighting = new();
public readonly GDSceneNodeService Scene = new();
public readonly Items.GDInventoryService Inventory = new();
public readonly Mods.GDModRegistryService ModRegistry = new();
private IActionContext _inner;
}
+1
View File
@@ -0,0 +1 @@
uid://bvdq2ib7ur3sp
+17
View File
@@ -0,0 +1,17 @@
using Godot;
namespace Cthangover.Core.Actions
{
[GlobalClass]
public partial class GDBattleService : RefCounted
{
internal IBattleService Inner;
public void Init(string sceneType, string enemies, string questId, string newTag)
=> Inner.Init(
sceneType,
enemies,
string.IsNullOrEmpty(questId) ? null : questId,
string.IsNullOrEmpty(newTag) ? null : newTag);
}
}
+1
View File
@@ -0,0 +1 @@
uid://bwq0fljl2uowx
+13
View File
@@ -0,0 +1,13 @@
using Godot;
namespace Cthangover.Core.Actions
{
[GlobalClass]
public partial class GDCharacterService : RefCounted
{
internal ICharacterService Inner;
public void AddToParty(string type) => Inner.AddToParty(type);
public void SendNotification(string type) => Inner.SendNotification(type);
}
}
+1
View File
@@ -0,0 +1 @@
uid://bdok1ap2hkrbb
+13
View File
@@ -0,0 +1,13 @@
using Godot;
namespace Cthangover.Core.Actions
{
[GlobalClass]
public partial class GDLightingService : RefCounted
{
internal ILightingService Inner;
public void ClearDepthMap() => Inner.ClearDepthMap();
public void SetUseTime(bool useTime) => Inner.SetUseTime(useTime);
}
}
+1
View File
@@ -0,0 +1 @@
uid://dsxwk05liagaq
+17
View File
@@ -0,0 +1,17 @@
using Godot;
namespace Cthangover.Core.Actions
{
[GlobalClass]
public partial class GDQuestService : RefCounted
{
internal IQuestService Inner;
public bool Exists(string id) => Inner.Exists(id);
public void SetStatus(string id, string status) => Inner.SetStatus(id, status);
public void SetDataStatus(string id, int level) => Inner.SetDataStatus(id, level);
public void AddTag(string id, string tag) => Inner.AddTag(id, tag);
public void RemoveTag(string id, string tag) => Inner.RemoveTag(id, tag);
public void SendNotification(string id) => Inner.SendNotification(id);
}
}
+1
View File
@@ -0,0 +1 @@
uid://df7b2ywyuc7fx
+13
View File
@@ -0,0 +1,13 @@
using Godot;
namespace Cthangover.Core.Actions
{
[GlobalClass]
public partial class GDSceneNodeService : RefCounted
{
internal ISceneNodeService Inner;
public void Instantiate(string scenePath, string nodeName) => Inner.Instantiate(scenePath, nodeName);
public void Remove(string nodeName) => Inner.Remove(nodeName);
}
}
+1
View File
@@ -0,0 +1 @@
uid://cimjjo0yddjtn
+23
View File
@@ -0,0 +1,23 @@
using Godot;
namespace Cthangover.Core.Actions;
public class GDScriptScenarioAction : IScenarioAction
{
private readonly GodotObject _instance;
public GDScriptScenarioAction(string name, GodotObject instance)
{
Name = name;
_instance = instance;
}
public string Name { get; }
public void Run(IActionContext ctx)
{
var gdCtx = new GDActionContext();
gdCtx.Core.Inner = ctx;
_instance.Call("run", gdCtx);
}
}
@@ -0,0 +1 @@
uid://cnjvrigf01lau
+90
View File
@@ -0,0 +1,90 @@
using Cthangover.Core.Items;
using Cthangover.Core.Mods;
namespace Cthangover.Core.Actions;
/// <summary>
/// Context passed to IScenarioAction.Run — provides dialog variable access
/// (GetParam) and references to all subsystem service interfaces. Acts as a
/// facade: a single object that grants the action access to quests, characters,
/// battle, lighting, scene nodes, mod registry, and inventory, plus logging.
/// GetParam reads from the DialogRuntime's variable store, so actions can
/// consume values set by the scenario DSL's "set" command or by earlier actions.
/// </summary>
public interface IActionContext
{
/// <summary>
/// Quest subsystem: full CRUD over quest state, status, tags, and UI
/// notifications. Backed by QuestServiceImpl which uses QuestFactory
/// for lookup and TryGet for safe fallback on missing quests.
/// </summary>
IQuestService Quests { get; }
/// <summary>
/// Character subsystem: add characters to the player's party and
/// dispatch "character joined" UI notifications. Accepts character
/// ID strings from scenario parameters.
/// </summary>
ICharacterService Character { get; }
/// <summary>
/// Battle subsystem: constructs BattleData from scenario parameters
/// (scene type, enemy list, quest binding) and stores it in runtime
/// data for consumption by the battle scene loader.
/// </summary>
IBattleService Battle { get; }
/// <summary>
/// Lighting subsystem: controls depth/albedo texture maps on the
/// UiLightController singleton and toggles time-of-day lighting.
/// Used for scene transitions with different lighting setups.
/// </summary>
ILightingService Lighting { get; }
/// <summary>
/// Scene node subsystem: runtime instantiation, removal, and typed
/// lookup of nodes within the current scene tree. All operations go
/// through SceneContextNode.Instance as the scene root.
/// </summary>
ISceneNodeService Scene { get; }
/// <summary>
/// Mod registry reference: allows scenario actions to query loaded
/// mods, their metadata, and custom action registrations at runtime.
/// Singleton instance shared across all contexts.
/// </summary>
IModRegistry ModRegistry { get; }
/// <summary>
/// Player inventory reference, obtained from GameData at context
/// construction time. May be null if game data hasn't fully loaded
/// yet — consumers must tolerate a null inventory.
/// </summary>
IInventory Inventory { get; }
/// <summary>
/// Reads a named variable from the dialog runtime's variable store.
/// Variables are populated by the scenario DSL's "set" command or by
/// earlier actions during dialog execution. Returns null when the
/// variable doesn't exist — callers must null-check. This is the sole
/// data channel between scenario actions and the dialog system.
/// </summary>
string GetParam(string name);
/// <summary>
/// Emits a debug-level entry to the central game log. The category
/// string groups related entries (convention: "EVENT", "QUEST",
/// "BATTLE", "SCENE", "WIDGET"). Consistent categories enable
/// log filtering during debugging of scenario scripts.
/// </summary>
void Log(string category, string message);
/// <summary>
/// Emits a warning-level entry to the central game log. The message
/// is prefixed with "[WARN]" and logged at LogLevel.Warning. Use
/// for non-fatal issues that scenario authors should investigate
/// (missing variables, unregistered quests, etc.) without halting
/// dialog execution.
/// </summary>
void LogWarning(string category, string message);
}
+1
View File
@@ -0,0 +1 @@
uid://c20w72cfxcqsd
+25
View File
@@ -0,0 +1,25 @@
namespace Cthangover.Core.Actions;
/// <summary>
/// Battle initiation contract for scenario actions. Init() constructs a
/// BattleData from scene background, enemy list, and optional quest binding.
/// The sceneType parameter maps to Godot scene types; enemies is a
/// comma-separated list resolved by BattleData.InitBattle.
/// </summary>
public interface IBattleService
{
/// <summary>
/// Constructs and stores BattleData for the upcoming battle encounter.
/// Captures the current scene background texture (via
/// BackgroundFactory using SceneContextNode.LastBackgroundID) and
/// depth/albedo lighting maps from UiLightController to preserve
/// visual context. The enemies string is a comma-separated list
/// resolved by BattleData.InitBattle. If questId is non-null, binds
/// the quest to the battle and optionally dispatches a notification
/// for newTag. Active battle core is resolved from BattleCoreRegistry
/// — wrapped in try/catch because the registry may be uninitialized
/// outside of battle contexts. The resulting BattleData is stored in
/// GameData.Instance.Runtime.BattleData for the battle loader.
/// </summary>
void Init(string sceneType, string enemies, string questId = null, string newTag = null);
}
+1
View File
@@ -0,0 +1 @@
uid://u4owqo1cjfay
+28
View File
@@ -0,0 +1,28 @@
namespace Cthangover.Core.Actions;
/// <summary>
/// Character manipulation contract for scenario actions. AddToParty recruits
/// a character by its string ID; SendNotification displays a UI notification
/// that a character has joined.
/// </summary>
public interface ICharacterService
{
/// <summary>
/// Adds a character to the player's party roster. The type string is
/// a character ID — it is passed directly to CharacterData.AddCharacterToParty
/// which handles roster mutation, persistence, and fallback for unknown IDs
/// (a minimal CharacterInfoData is created with default attributes when
/// no template is found).
/// </summary>
void AddToParty(string type);
/// <summary>
/// Displays a "character joined" UI notification without modifying
/// the party roster. Used when recruitment happens through an
/// external mechanism but the player still needs visual feedback,
/// or when replaying a notification for an already-recruited
/// character. The type string is a character ID passed directly
/// to CharacterData.SendAddNotification.
/// </summary>
void SendNotification(string type);
}
+1
View File
@@ -0,0 +1 @@
uid://bl6y11rjgmexk
+31
View File
@@ -0,0 +1,31 @@
namespace Cthangover.Core.Actions;
/// <summary>
/// Lighting control contract for scenario actions. ClearDepthMap resets the
/// depth/albedo textures on the UiLightController singleton, effectively
/// removing scene-specific lighting masks. SetUseTime toggles time-of-day
/// lighting on/off.
/// </summary>
public interface ILightingService
{
/// <summary>
/// Resets both the depth texture and albedo texture on the
/// UiLightController singleton to null. This disables scene-specific
/// lighting masks, reverting the shader to default flat lighting.
/// Both maps are cleared together because partial clearing would
/// leave the lighting shader in an inconsistent state. Used during
/// scene transitions — call before entering a scene that doesn't
/// use depth-based lighting, or when switching between scenes with
/// different lighting setups.
/// </summary>
void ClearDepthMap();
/// <summary>
/// Enables or disables time-of-day lighting on the UiLightController.
/// When enabled, the controller adjusts scene lighting based on the
/// in-game time; when disabled, lighting remains static. Use this
/// when entering interior scenes (disable) or exterior scenes
/// (enable) to match the environmental context.
/// </summary>
void SetUseTime(bool useTime);
}
+1
View File
@@ -0,0 +1 @@
uid://bv26w63b0w31y
+71
View File
@@ -0,0 +1,71 @@
using Cthangover.Core.Quests;
namespace Cthangover.Core.Actions;
/// <summary>
/// Quest manipulation contract for scenario actions. Provides full CRUD over
/// quest state: Get by ID, status change (parsed via Enums&lt;QuestStatus&gt;),
/// data-level status tracking, tag management, and UI notification dispatch.
/// Separates quest.Status (global state like Active/Completed) from
/// quest.Data.Status (incremental progress within the quest).
/// </summary>
public interface IQuestService
{
/// <summary>
/// Retrieves a quest by its string ID from QuestFactory. Throws
/// KeyNotFoundException if the quest doesn't exist — prefer
/// Exists() or the safe TryGet() pattern in the implementation
/// when the quest may legitimately be absent.
/// </summary>
IQuest Get(string id);
/// <summary>
/// Checks whether a quest with the given ID is registered in
/// QuestFactory. Returns false on any exception (missing data,
/// corrupted save), treating "can't read" as "doesn't exist"
/// to prevent scenario scripts from crashing.
/// </summary>
bool Exists(string id);
/// <summary>
/// Sets the global lifecycle state of a quest (Active, Completed,
/// Failed, etc.). The status string is parsed via
/// Enums&lt;QuestStatus&gt;.Parse — case-sensitive. Invalid status
/// strings are caught and logged as errors without throwing.
/// </summary>
void SetStatus(string id, string status);
/// <summary>
/// Sets the numeric progress level within a quest's Data.Status
/// field. This is incremental progress (e.g. "2/5 wolves killed"),
/// separate from the global quest state managed by SetStatus().
/// The quest's data object tracks the level for display and
/// completion checks.
/// </summary>
void SetDataStatus(string id, int level);
/// <summary>
/// Attaches a string tag to a quest for use in conditional logic.
/// Tags are stored in the quest's save data and queried by scenario
/// scripts via the "has_tag" condition to branch dialog or gate
/// quest progression. Adding a tag that already exists is a no-op.
/// </summary>
void AddTag(string id, string tag);
/// <summary>
/// Removes a string tag from a quest. Used to clear temporary
/// conditions — for example, removing a "witnessed_event" tag
/// after the event has been addressed. Removing a non-existent
/// tag is a no-op.
/// </summary>
void RemoveTag(string id, string tag);
/// <summary>
/// Triggers a UI notification for the quest (e.g. "Quest Updated"
/// or "New Quest" popup). This is the visual feedback companion
/// to state changes — call after SetStatus/SetDataStatus for the
/// player to see the update. The notification itself does not
/// change quest state.
/// </summary>
void SendNotification(string id);
}
+1
View File
@@ -0,0 +1 @@
uid://bjedcnwukcm7d
+34
View File
@@ -0,0 +1,34 @@
namespace Cthangover.Core.Actions;
/// <summary>
/// Contract for atomic scenario actions — named commands that the scenario DSL
/// dispatches at runtime via ScenarioActionFactory. Each action has a unique
/// Name (e.g. "quest.set_status", "battle.init") used as the registry key.
/// Actions receive an IActionContext giving access to dialog variables and
/// subsystem services (quests, battle, characters, lighting, scene, inventory).
/// Implementations are discovered via reflection and require no manual
/// registration — the factory scans all assemblies for IScenarioAction types.
/// </summary>
public interface IScenarioAction
{
/// <summary>
/// Unique action identifier used as the registry key in
/// ScenarioActionFactory. Follows the "subsystem.verb" convention
/// (e.g. "quest.set_status", "battle.init", "scene.instantiate").
/// This is the name that scenario DSL scripts use after the "action"
/// keyword. Must be unique across all registered actions — if a
/// mod registers an action with a duplicate name, it is silently
/// ignored (earliest registration wins).
/// </summary>
string Name { get; }
/// <summary>
/// Executes the action using the provided context. The context
/// provides access to dialog variables (via GetParam) and all
/// subsystem services. Run() is called synchronously by the dialog
/// engine when it encounters the corresponding action command in
/// a scenario script — the dialog pauses until Run() returns, so
/// actions must not block indefinitely.
/// </summary>
void Run(IActionContext context);
}
+1
View File
@@ -0,0 +1 @@
uid://1he14yox77dj
+43
View File
@@ -0,0 +1,43 @@
using Godot;
namespace Cthangover.Core.Actions;
/// <summary>
/// Scene node manipulation contract for scenario actions. Instantiate loads
/// a PackedScene and adds it as a child of SceneContextNode.Instance (the
/// current scene root). Remove finds a child by name and frees it. Find
/// provides typed lookup — used by TogglePanelAction to toggle Control
/// visibility in the scene.
/// </summary>
public interface ISceneNodeService
{
/// <summary>
/// Loads a PackedScene from the given resource path via
/// GD.Load&lt;PackedScene&gt;, instantiates it, assigns the given
/// node name, and attaches it as a child of
/// SceneContextNode.Instance (the current scene's autoload root).
/// The node becomes visible immediately. Logs an error if the
/// resource fails to load (wrong path, missing file) rather than
/// throwing — this keeps the dialog running even with broken
/// scene references.
/// </summary>
void Instantiate(string scenePath, string nodeName);
/// <summary>
/// Removes a named child node from the scene tree and frees it.
/// First calls SceneContextNode.RemoveEventObject to notify the
/// event system of the pending destruction (prevents stale event
/// references), then performs RemoveChild + QueueFree. Safe to
/// call on non-existent nodes — silently returns.
/// </summary>
void Remove(string nodeName);
/// <summary>
/// Performs a typed recursive search for a node by name across the
/// entire scene tree, starting from SceneContextNode.Instance.
/// Returns null if no matching node of type T with the given name
/// exists. Used by actions like TogglePanelAction to locate UI
/// controls regardless of nesting depth.
/// </summary>
T Find<T>(string nodeName) where T : Node;
}
+1
View File
@@ -0,0 +1 @@
uid://b7k51qhnntc53
+40
View File
@@ -0,0 +1,40 @@
using Cthangover.Core.UI.Lights;
namespace Cthangover.Core.Actions;
/// <summary>
/// Thin wrapper around UiLightController.Instance. ClearDepthMap sets both
/// depth and albedo to null simultaneously — partial clearing would leave
/// the shader in an inconsistent state. SetUseTime delegates to the
/// controller's IsUseLight property.
/// </summary>
internal class LightingServiceImpl : ILightingService
{
/// <summary>
/// Sets both depth and albedo maps to null on UiLightController.
/// Both are cleared together because the lighting shader requires
/// either both textures present or neither — partial clearing
/// produces visual artifacts. Safe to call when the controller
/// hasn't been initialized yet (null-conditional access).
/// </summary>
public void ClearDepthMap()
{
var controller = UiLightController.Instance;
controller?.SetupDepthMap(null);
controller?.SetupAlbedoMap(null);
}
/// <summary>
/// Toggles the IsUseLight property on UiLightController. When
/// true, the controller applies time-of-day lighting adjustments;
/// when false, lighting remains static. Null-safe: if the
/// controller singleton is absent (pre-initialization), the call
/// is silently skipped.
/// </summary>
public void SetUseTime(bool useTime)
{
var controller = UiLightController.Instance;
if (controller != null)
controller.IsUseLight = useTime;
}
}
+1
View File
@@ -0,0 +1 @@
uid://bik36g8o0uk47
+151
View File
@@ -0,0 +1,151 @@
using System;
using Cthangover.Core.Events;
using Cthangover.Core.Quests;
using Cthangover.Core.Utils;
namespace Cthangover.Core.Actions
{
/// <summary>
/// Quest service implementation with internal TryGet for safe fallback.
/// Public methods (SetStatus, SetDataStatus, AddTag, etc.) use TryGet which
/// catches KeyNotFoundException from QuestFactory — this prevents scenario
/// scripts from crashing when referencing non-existent quests. The separation
/// of SetStatus (global quest state like Active/Completed) from SetDataStatus
/// (numeric progress level) matches QuestBase's dual-state design. Exists()
/// returns false on any exception, so missing or broken quest data is treated
/// as "doesn't exist" rather than throwing.
/// </summary>
internal class QuestServiceImpl : IQuestService
{
/// <summary>
/// Direct lookup from QuestFactory by ID. Throws
/// KeyNotFoundException if the quest doesn't exist — use
/// Exists() or TryGet() when the quest may legitimately be
/// absent from the registry.
/// </summary>
public IQuest Get(string id) => QuestFactory.Instance.Get(id);
/// <summary>
/// Safe existence check. Wraps QuestFactory.Get in try/catch
/// and returns false on any exception — corrupted save data or
/// broken quest definitions are treated as "doesn't exist"
/// rather than propagating errors to the dialog engine.
/// </summary>
public bool Exists(string id)
{
try { return QuestFactory.Instance.Get(id) != null; }
catch { return false; }
}
/// <summary>
/// Internal safe-get pattern that catches KeyNotFoundException
/// from QuestFactory. Returns null for missing quests so
/// callers can use the null-conditional operator (?.) rather
/// than try/catch blocks. All mutating methods (SetStatus,
/// AddTag, etc.) route through TryGet to prevent scenario
/// scripts from crashing on broken quest references.
/// </summary>
public QuestBase TryGet(string id)
{
QuestBase result = null;
try
{
result = QuestFactory.Instance.Get(id);
}
catch(Exception ex)
{
GameLogger.Log("QUEST", $"try get quest '{id}' exception - {ex.Message}");
}
return result;
}
/// <summary>
/// Sets the global quest status (Active, Completed, Failed,
/// etc.) by parsing the status string via
/// Enums&lt;QuestStatus&gt;.Parse. Invalid status strings are
/// caught and logged as errors without throwing — the dialog
/// continues with the quest unchanged. Routes through TryGet
/// so missing quests are silently skipped. Publishes a
/// <see cref="QuestStatusChangedEvent"/> only when the status
/// actually changed; setting the same status is a no-op that
/// publishes nothing.
/// </summary>
public void SetStatus(string id, string status)
{
var quest = TryGet(id);
if(quest == null)
return;
try
{
var newStatus = Enums<QuestStatus>.Parse(status);
if (quest.Status == newStatus)
return;
var oldStatus = quest.Status.ToString();
quest.Status = newStatus;
ModEventBus.Publish(new QuestStatusChangedEvent
{
QuestId = id,
OldStatus = oldStatus,
NewStatus = status,
});
}
catch (Exception ex)
{
GameLogger.Log("QUEST", $"status '{status}' invalid - {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Updates the numeric progress level within the quest's data
/// object (quest.Data.Status). Separate from SetStatus which
/// controls the global lifecycle state. Routes through TryGet
/// with null-conditional access — if the quest doesn't exist,
/// the call is silently skipped. No bounds checking: the caller
/// is responsible for meaningful level values. The
/// <see cref="QuestDataStatusChangedEvent"/> is published by
/// <see cref="QuestData.SetStatus"/> itself.
/// </summary>
public void SetDataStatus(string id, int level)
{
TryGet(id)?.Data.SetStatus(level);
}
/// <summary>
/// Attaches a string tag to the quest's tag collection via
/// QuestBase.AddTag. Tags are persisted with save data and
/// queried by scenario conditions (has_tag). Adding a duplicate
/// tag is handled by the underlying collection. Routes through
/// TryGet for safe fallback on missing quests. The
/// <see cref="QuestTagChangedEvent"/> is published by
/// <see cref="QuestBase.AddTag"/> itself (change-only).
/// </summary>
public void AddTag(string id, string tag)
{
TryGet(id)?.AddTag(tag);
}
/// <summary>
/// Removes a tag from the quest. Used for clearing temporary
/// conditions in branching scenarios. Removing a non-existent
/// tag is a collection-level no-op. Routes through TryGet. The
/// <see cref="QuestTagChangedEvent"/> is published by
/// <see cref="QuestBase.RemoveTag"/> itself (change-only).
/// </summary>
public void RemoveTag(string id, string tag)
{
TryGet(id)?.RemoveTag(tag);
}
/// <summary>
/// Dispatches a UI notification for the quest (e.g. "Quest
/// Updated" popup) via QuestBase.SendNotification. Does not
/// modify quest state — call SetStatus/SetDataStatus before
/// this to ensure the notification reflects the new state.
/// Routes through TryGet.
/// </summary>
public void SendNotification(string id)
{
TryGet(id)?.SendNotification();
}
}
}
+1
View File
@@ -0,0 +1 @@
uid://cr2l0a833awiu
+103
View File
@@ -0,0 +1,103 @@
using Cthangover.Core.Items;
using Cthangover.Core.Mods;
using Cthangover.Core.Settings;
using Cthangover.Core.UI.Dialog;
using Cthangover.Core.Utils;
namespace Cthangover.Core.Actions
{
/// <summary>
/// Concrete IActionContext wired to a DialogRuntime. Each service property
/// is instantiated inline (no DI container) — this is intentional: scenario
/// actions are short-lived commands, so creating new service instances per
/// context is cheap and avoids shared state. GetParam delegates to the
/// dialog runtime's variable store, bridging the dialog DSL's "set" variables
/// to the atomic action system. The Inventory reference is try-caught because
/// it may not exist at the moment of context creation (e.g. before game data
/// is fully loaded).
/// </summary>
internal class ScenarioActionContext : IActionContext
{
private readonly DialogRuntime runtime;
/// <summary>
/// Service accessor for quest operations. Instantiated inline
/// (new QuestServiceImpl()) — each context gets a fresh instance
/// because scenario actions are short-lived commands and service
/// creation is cheap. QuestServiceImpl uses QuestFactory for
/// lookup and TryGet for safe fallback.
/// </summary>
public IQuestService Quests { get; } = new QuestServiceImpl();
/// <summary>
/// Service accessor for character party operations. Instantiated
/// per-context. Routes to CharacterData via strict enum parsing.
/// </summary>
public ICharacterService Character { get; } = new CharacterServiceImpl();
/// <summary>
/// Service accessor for battle initiation. Instantiated
/// per-context. Constructs BattleData capturing current background
/// and lighting state at the moment of Init() call.
/// </summary>
public IBattleService Battle { get; } = new BattleServiceImpl();
/// <summary>
/// Service accessor for lighting control. Instantiated per-context.
/// Thin wrapper around UiLightController singleton.
/// </summary>
public ILightingService Lighting { get; } = new LightingServiceImpl();
/// <summary>
/// Service accessor for runtime scene node manipulation.
/// Instantiated per-context. All operations route through
/// SceneContextNode.Instance as the scene root.
/// </summary>
public ISceneNodeService Scene { get; } = new SceneNodeServiceImpl();
/// <summary>
/// Singleton reference to the mod registry. Shared across all
/// contexts because mod registration is global and mod data
/// doesn't change during a single dialog execution.
/// </summary>
public IModRegistry ModRegistry { get; } = Cthangover.Core.Mods.ModRegistry.Instance;
/// <summary>
/// Player inventory reference, captured once at context
/// construction time. Try-caught because GameData may not be
/// fully loaded when the context is created (e.g. during early
/// dialog initialization before the game scene is ready).
/// Consumers must tolerate a null inventory.
/// </summary>
public IInventory Inventory { get; }
/// <summary>
/// Creates a context wired to the given DialogRuntime. The
/// runtime provides the variable store for GetParam — without
/// it, actions would have no way to receive parameters from the
/// scenario DSL. Inventory is captured eagerly (not lazily)
/// because it's fixed for the lifetime of this context.
/// </summary>
public ScenarioActionContext(DialogRuntime runtime)
{
this.runtime = runtime;
try { Inventory = GameData.Instance?.Runtime?.Inventory; }
catch { Inventory = null; }
}
/// <summary>
/// Reads a named variable from the dialog runtime's variable
/// store. This is the bridge between the scenario DSL's "set"
/// command and the atomic action system — variables set in the
/// script are consumed here. Returns null for undefined variables.
/// </summary>
public string GetParam(string name) => runtime.GetVariable(name);
/// <summary>
/// Emits a debug-level log entry via GameLogger. The category
/// groups related entries for filtering; use consistent
/// categories (e.g. "EVENT", "QUEST", "BATTLE") across actions.
/// </summary>
public void Log(string category, string message) => GameLogger.Log(category, message);
/// <summary>
/// Emits a warning-level log entry. Automatically prepends
/// "[WARN]" to the message and logs at LogLevel.Warning.
/// </summary>
public void LogWarning(string category, string message) => GameLogger.Log(category, $"[WARN] {message}", LogLevel.Warning);
}
}
@@ -0,0 +1 @@
uid://bepeif8dqybbn
+73
View File
@@ -0,0 +1,73 @@
using Cthangover.Core.Scenes;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Core.Actions
{
/// <summary>
/// Scene node operations implementation. Instantiate loads a PackedScene by
/// path and attaches it to SceneContextNode.Instance — the current scene's
/// autoload root. Remove delegates to SceneContextNode.RemoveEventObject
/// before the raw RemoveChild+QueueFree, suggesting the event system tracks
/// instantiated objects and needs cleanup notification. Find uses
/// SceneContextNode.FindNode for typed recursive lookup across the scene tree.
/// </summary>
internal class SceneNodeServiceImpl : ISceneNodeService
{
/// <summary>
/// Loads a PackedScene by resource path via GD.Load, instantiates
/// it, assigns the node name, and adds it as a child of
/// SceneContextNode.Instance (the current scene's autoload root).
/// If the resource fails to load (wrong path, missing file), logs
/// an error and returns without throwing — the dialog continues.
/// The instantiated node becomes visible immediately.
/// </summary>
public void Instantiate(string scenePath, string nodeName)
{
var packedScene = GD.Load<PackedScene>(scenePath);
if (packedScene == null)
{
GameLogger.Log("SCENE", $"SceneNodeService: failed to load '{scenePath}'", LogLevel.Error);
return;
}
var instance = packedScene.Instantiate();
instance.Name = nodeName;
SceneContextNode.Instance?.AddChild(instance);
}
/// <summary>
/// Removes a named child node from SceneContextNode.Instance.
/// First notifies the event system via RemoveEventObject to clean
/// up any event subscriptions referencing the node, then performs
/// RemoveChild + QueueFree. Safe to call on non-existent nodes
/// (silently returns if the context or child is null).
/// </summary>
public void Remove(string nodeName)
{
var ctx = SceneContextNode.Instance;
if (ctx == null)
return;
ctx.RemoveEventObject(nodeName);
var child = ctx.FindChild(nodeName, false, false);
if (child != null)
{
ctx.RemoveChild(child);
child.QueueFree();
}
}
/// <summary>
/// Typed recursive lookup by node name across the entire scene
/// tree via SceneContextNode.FindNode. Returns null if no
/// matching node of type T with the given name exists. Used by
/// TogglePanelAction to locate UI controls at any nesting depth.
/// </summary>
public T Find<T>(string nodeName) where T : Node
{
return SceneContextNode.FindNode<T>(nodeName);
}
}
}
+1
View File
@@ -0,0 +1 @@
uid://dn6emcv75xnch
+421
View File
@@ -0,0 +1,421 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Settings;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Core.Audio
{
/// <summary>
/// Singleton audio hub that owns three independent buses (Music, SFX, Ambient),
/// creating them at runtime if the audio setup lacks them. For sound effects,
/// AudioStreamPlayer nodes are pooled by SoundType so overlapping sounds
/// within the same category are cut off, while different categories stack.
/// Settings are polled each frame rather than event-driven, detecting
/// volume/enabled changes cheaply.
/// Exposes LinearToDb as a public static helper used elsewhere (e.g. UI sliders).
/// GetExpLevel / GetLowLevel provide logarithmic vs linear volume scaling
/// for contexts that need one or the other.
/// </summary>
public partial class AudioService : Node, IAudioService
{
private AudioStreamPlayer musicPlayer;
private AudioStreamPlayer ambientPlayer;
private readonly Dictionary<SoundType, AudioStreamPlayer> soundPlayers = new();
private int musicBusIndex;
private int soundBusIndex;
private int ambientBusIndex;
private const string MusicBusName = "Music";
private const string SoundBusName = "SFX";
private const string AmbientBusName = "Ambient";
private bool? lastSoundsEnabled;
private bool? lastMusicsEnabled;
private bool? lastAmbientEnabled;
private Random rnd = new Random();
public override void _Ready()
{
AddToGroup("AudioService");
SetupBuses();
SetupMusicPlayer();
SetupAmbientPlayer();
ApplySettings();
}
public override void _Process(double delta)
{
var settings = GameData.Instance?.Settings;
if (settings == null)
return;
if (settings.SoundsEnabled != lastSoundsEnabled ||
settings.SoundsVolume != lastSoundsVolume ||
settings.MusicsEnabled != lastMusicsEnabled ||
settings.MusicsVolume != lastMusicsVolume ||
settings.AmbientEnabled != lastAmbientEnabled ||
settings.AmbientVolume != lastAmbientVolume)
{
ApplySettings();
}
}
private int lastSoundsVolume = -1;
private int lastMusicsVolume = -1;
private int lastAmbientVolume = -1;
private void ApplySettings()
{
var settings = GameData.Instance?.Settings;
if (settings == null)
return;
GameLogger.Log("AUDIO", $"ApplySettings sounds={settings.SoundsEnabled}/{settings.SoundsVolume} musics={settings.MusicsEnabled}/{settings.MusicsVolume} ambient={settings.AmbientEnabled}/{settings.AmbientVolume}");
lastSoundsEnabled = settings.SoundsEnabled;
lastMusicsEnabled = settings.MusicsEnabled;
lastAmbientEnabled = settings.AmbientEnabled;
lastSoundsVolume = settings.SoundsVolume;
lastMusicsVolume = settings.MusicsVolume;
lastAmbientVolume = settings.AmbientVolume;
float soundVol = settings.SoundsEnabled ? settings.SoundsVolume : 0;
float musicVol = settings.MusicsEnabled ? settings.MusicsVolume : 0;
float ambientVol = settings.AmbientEnabled ? settings.AmbientVolume : 0;
AudioServer.SetBusVolumeDb(soundBusIndex, LinearToDb(soundVol / 100f));
AudioServer.SetBusVolumeDb(musicBusIndex, LinearToDb(musicVol / 100f));
AudioServer.SetBusVolumeDb(ambientBusIndex, LinearToDb(ambientVol / 100f));
}
private void SetupBuses()
{
musicBusIndex = AudioServer.GetBusIndex(MusicBusName);
if (musicBusIndex < 0)
{
musicBusIndex = AudioServer.GetBusCount();
AudioServer.AddBus(musicBusIndex);
AudioServer.SetBusName(musicBusIndex, MusicBusName);
}
soundBusIndex = AudioServer.GetBusIndex(SoundBusName);
if (soundBusIndex < 0)
{
soundBusIndex = AudioServer.GetBusCount();
AudioServer.AddBus(soundBusIndex);
AudioServer.SetBusName(soundBusIndex, SoundBusName);
}
ambientBusIndex = AudioServer.GetBusIndex(AmbientBusName);
if (ambientBusIndex < 0)
{
ambientBusIndex = AudioServer.GetBusCount();
AudioServer.AddBus(ambientBusIndex);
AudioServer.SetBusName(ambientBusIndex, AmbientBusName);
}
}
private void SetupMusicPlayer()
{
musicPlayer = new AudioStreamPlayer();
musicPlayer.Name = "MusicPlayer";
musicPlayer.Bus = MusicBusName;
AddChild(musicPlayer);
}
private void SetupAmbientPlayer()
{
ambientPlayer = new AudioStreamPlayer();
ambientPlayer.Name = "AmbientPlayer";
ambientPlayer.Bus = AmbientBusName;
ambientPlayer.Finished += OnAmbientFinished;
AddChild(ambientPlayer);
}
private void OnAmbientFinished()
{
if (ambientPlayer?.Stream != null)
ambientPlayer.Play();
}
private AudioStreamPlayer GetOrCreateSoundPlayer(SoundType type)
{
if (soundPlayers.TryGetValue(type, out var player) && IsInstanceValid(player))
return player;
player = new AudioStreamPlayer();
player.Name = $"SFX_{type}";
player.Bus = SoundBusName;
AddChild(player);
soundPlayers[type] = player;
return player;
}
/// <summary>
/// Loads a music stream via <c>MusicFactory</c> and hands it to
/// the dedicated Music player. If either the player or the
/// factory result is null the call is silently skipped — no
/// exception is thrown so that callers can fire-and-forget
/// without guarding against missing assets.
/// </summary>
public void PlayMusic(string id, MusicType musicType)
{
if (musicPlayer == null)
return;
var stream = MusicFactory.Instance?.Get(id);
if (stream == null)
return;
GameLogger.Log("AUDIO", $"AudioService.PlayMusic '{id}' type={musicType}");
musicPlayer.Stream = stream;
musicPlayer.Play();
}
/// <summary>
/// Stops playback on the Music player. The stream reference is
/// kept intact so a subsequent <c>Play()</c> would restart the
/// same track — external logic (e.g. auto-advance) is expected
/// to replace the stream before the next play.
/// </summary>
public void StopMusic()
{
musicPlayer?.Stop();
}
/// <summary>
/// Toggles the <c>StreamPaused</c> flag on the Music player.
/// When <paramref name="pause"/> is <c>true</c> playback is
/// suspended in-place; when <c>false</c> it resumes from the
/// exact sample. No stream replacement occurs.
/// </summary>
public void PauseMusic(bool pause)
{
if (musicPlayer == null)
return;
musicPlayer.StreamPaused = pause;
}
/// <summary>
/// Returns <c>true</c> when the Music player exists and is
/// currently producing audio. Used externally (e.g. UI) to
/// display playback state without coupling to the player
/// node directly.
/// </summary>
public bool IsMusicPlaying()
{
return musicPlayer?.Playing ?? false;
}
/// <summary>
/// Resolves an ambient loop from <c>SoundFactory</c> and plays it
/// on the Ambient player. If the same stream is already playing
/// the call is a no-op, preventing a disruptive restart glitch.
/// When the stream ends the player automatically loops via the
/// <c>Finished</c> signal.
/// </summary>
public void PlayAmbient(string id)
{
if (ambientPlayer == null)
return;
var stream = SoundFactory.Instance?.Get(id);
if (stream == null)
{
GameLogger.Log("AUDIO", $"AudioService.PlayAmbient: stream not found for '{id}'", LogLevel.Error);
return;
}
if (ambientPlayer.Playing && ambientPlayer.Stream == stream)
return;
GameLogger.Log("AUDIO", $"AudioService.PlayAmbient '{id}'");
ambientPlayer.Stream = stream;
ambientPlayer.Play();
}
/// <summary>
/// Stops the Ambient loop. Only acts when the player exists and
/// is actively playing; otherwise the call is a no-op. There is
/// no auto-resume — the next <c>PlayAmbient</c> loads the stream
/// from scratch.
/// </summary>
public void StopAmbient()
{
if (ambientPlayer == null || !ambientPlayer.Playing)
return;
GameLogger.Log("AUDIO", "AudioService.StopAmbient");
ambientPlayer.Stop();
}
/// <summary>
/// Plays a sound effect from <c>SoundFactory</c> with optional
/// random variation. If <paramref name="variations"/> is greater
/// than 1, a suffix <c>"_N"</c> is appended to <paramref name="id"/>
/// where N is uniformly random in [1, variations]. The result is
/// played on the per-<paramref name="soundType"/> pool player,
/// cutting off any previous sound of the same type.
/// </summary>
public void PlaySound(string id, int variations, SoundType soundType)
{
if (variations < 1)
{
GameLogger.Log("AUDIO", $"PlaySound '{id}' type={soundType} with invalid variations={variations}");
return;
}
if (variations > 1)
id += "_" + (rnd.Next(variations) + 1).ToString();
var stream = SoundFactory.Instance?.Get(id);
if (stream == null)
return;
GameLogger.Log("AUDIO", $"PlaySound '{id}' type={soundType}");
var player = GetOrCreateSoundPlayer(soundType);
player.Stream = stream;
player.Play();
}
/// <summary>
/// Convenience overload that delegates to
/// <c>PlaySound(id, 1, soundType)</c> — no variation suffix is
/// appended, so the exact asset name is used.
/// </summary>
public void PlaySound(string id, SoundType soundType)
{
PlaySound(id, 1, soundType);
}
/// <summary>
/// Stops the pooled player for a specific <see cref="SoundType"/>.
/// Other sound-type players are not affected, so e.g. stopping
/// <c>CardEffect</c> won't interrupt an ongoing <c>UI</c> sound.
/// </summary>
public void StopSound(SoundType type)
{
if (soundPlayers.TryGetValue(type, out var player) && IsInstanceValid(player))
player.Stop();
}
/// <summary>
/// Pauses the player for the given <paramref name="type"/> by
/// setting <c>StreamPaused = true</c>. A subsequent
/// <c>PlaySound</c> call will restart it from the paused
/// position because the stream is already loaded.
/// </summary>
public void PauseSound(SoundType type)
{
if (soundPlayers.TryGetValue(type, out var player) && IsInstanceValid(player))
player.StreamPaused = true;
}
/// <summary>
/// Applies a linear volume (01) directly to the Godot audio bus
/// identified by <paramref name="type"/>. The value is converted
/// to dB via <c>LinearToDb</c>. Unlike the per-frame settings
/// poll, this is an immediate one-shot override — useful for
/// fade tweens and manual volume control outside the settings
/// system.
/// </summary>
public void SetVolume(MixerType type, float volume)
{
GameLogger.Log("AUDIO", $"SetVolume {type} = {volume:F2}");
float db = LinearToDb(volume);
if (type == MixerType.Musics)
AudioServer.SetBusVolumeDb(musicBusIndex, db);
else if (type == MixerType.Ambient)
AudioServer.SetBusVolumeDb(ambientBusIndex, db);
else
AudioServer.SetBusVolumeDb(soundBusIndex, db);
}
/// <summary>
/// Silences the bus by forcing -80 dB when
/// <paramref name="enabled"/> is <c>false</c>. When <c>true</c>
/// the method returns immediately without restoring volume —
/// the next settings poll or <c>SetVolume</c> call is expected
/// to bring the bus back to its intended level. This asymmetry
/// exists because the settings system is the primary volume
/// driver and <c>SetEnabled(false)</c> is a hard mute overlay.
/// </summary>
public void SetEnabled(MixerType type, bool enabled)
{
if (enabled)
return;
float db = -80f;
if (type == MixerType.Musics)
AudioServer.SetBusVolumeDb(musicBusIndex, db);
else if (type == MixerType.Ambient)
AudioServer.SetBusVolumeDb(ambientBusIndex, db);
else
AudioServer.SetBusVolumeDb(soundBusIndex, db);
}
/// <summary>
/// Computes a logarithmic (dB) level for scenarios that need
/// exponential volume scaling (e.g. UI sliders). First clamps
/// the current bus level scaled by <paramref name="percent"/>
/// to (0.0001, 100], then converts to dB via
/// <c>log10(level/100) * 20</c>. The clamping avoids
/// <c>-infinity</c> dB at zero.
/// </summary>
public float GetExpLevel(MixerType type, float percent = 1f)
{
var level = GetLevel(type) * percent;
if (level > 100) level = 100;
if (level <= 0) level = 0.0001f;
return Mathf.Log(level / 100f) / Mathf.Log(10) * 20f;
}
/// <summary>
/// Returns the current bus level as a 01 linear factor by
/// dividing the raw 0100 settings value by 100. Used where
/// linear interpolation is more appropriate than dB scaling.
/// </summary>
public float GetLowLevel(MixerType type)
{
return GetLevel(type) / 100f;
}
private float GetLevel(MixerType type)
{
var settings = GameData.Instance?.Settings;
if (settings == null)
return 100;
return type switch
{
MixerType.Sounds => settings.SoundsEnabled ? settings.SoundsVolume : 0,
MixerType.Musics => settings.MusicsEnabled ? settings.MusicsVolume : 0,
MixerType.Ambient => settings.AmbientEnabled ? settings.AmbientVolume : 0,
_ => 100,
};
}
/// <summary>
/// Converts a linear amplitude (01) to decibels using the
/// <c>20 * log10(linear)</c> formula. Values at or below
/// 0.0001 are clamped to -80 dB to avoid <c>-infinity</c>.
/// Exposed as <c>public static</c> so external code (UI
/// sliders, settings panels) can perform the conversion
/// without holding a reference to the service.
/// </summary>
public static float LinearToDb(float linear)
{
if (linear <= 0.0001f)
return -80f;
return Mathf.Log(linear) / Mathf.Log(10) * 20f;
}
}
}
+1
View File
@@ -0,0 +1 @@
uid://rn4u7ptpjlyw
+109
View File
@@ -0,0 +1,109 @@
namespace Cthangover.Core.Audio
{
/// <summary>
/// Contract for the central audio service. Separates per-bus playback
/// (music, ambient, SFX) and volume/enabled control. The PlaySound
/// overload with <c>variations</c> picks a random suffix from 1..N,
/// enabling sound variation without explicit variant IDs in callers.
/// </summary>
public interface IAudioService
{
/// <summary>
/// Loads a music track by asset <paramref name="id"/> through
/// <c>MusicFactory</c> and assigns it to the dedicated Music
/// <c>AudioStreamPlayer</c>. The <paramref name="musicType"/>
/// tag is informational — it does not change playback routing;
/// the caller is responsible for using the correct type so that
/// auto-advance logic in <c>MusicPlayerBehaviour</c> picks the
/// right playlist bucket on track end.
/// </summary>
void PlayMusic(string id, MusicType musicType);
/// <summary>
/// Immediately stops the dedicated music player. Any saved
/// playback position in <c>PlaylistContext</c> is preserved,
/// allowing a later <c>PlayMusic</c> or auto-advance to pick
/// a fresh track or resume the saved one.
/// </summary>
void StopMusic();
/// <summary>
/// Pauses or resumes the music stream in-place via
/// <c>StreamPaused</c>. The current track is not evicted, so
/// a subsequent <c>PauseMusic(false)</c> continues from the
/// exact sample where audio was suspended.
/// </summary>
void PauseMusic(bool pause);
/// <summary>
/// Plays a single sound effect identified by <paramref name="id"/>
/// through the per-<see cref="SoundType"/> player pool. If a
/// sound of the same <paramref name="soundType"/> is already
/// playing it is cut off, while sounds of different types
/// stack independently.
/// </summary>
void PlaySound(string id, SoundType soundType);
/// <summary>
/// Plays a sound effect with random variation. When
/// <paramref name="variations"/> is greater than 1, a suffix
/// <c>"_N"</c> (1..variations) is appended to <paramref name="id"/>
/// so the caller can provide a base name like <c>"footstep"</c>
/// and get <c>"footstep_3"</c> at runtime without enumerating
/// variants manually.
/// </summary>
void PlaySound(string id, int variations, SoundType soundType);
/// <summary>
/// Stops the player associated with the given
/// <paramref name="type"/> pool. Other <see cref="SoundType"/>
/// players are unaffected.
/// </summary>
void StopSound(SoundType type);
/// <summary>
/// Pauses the per-type sound player. Only the specified
/// <paramref name="type"/> is affected; other pools keep
/// playing. Use <c>PlaySound</c> to restart — the stream
/// resumes from where it was paused because <c>Play()</c>
/// on an already-loaded <c>AudioStreamPlayer</c> continues
/// from the paused position.
/// </summary>
void PauseSound(SoundType type);
/// <summary>
/// Starts or restarts an ambient loop on the dedicated Ambient
/// bus. If the same ambient stream is already playing the call
/// is silently ignored, preventing an audible reset glitch.
/// On track end the player automatically re-plays the stream.
/// </summary>
void PlayAmbient(string id);
/// <summary>
/// Stops the ambient loop. Unlike the music bus there is no
/// auto-advance or saved state — calling <c>PlayAmbient</c>
/// again starts the stream from the beginning.
/// </summary>
void StopAmbient();
/// <summary>
/// Sets the linear volume (01) for the audio bus identified by
/// <paramref name="type"/>. The value is converted to dB via
/// <c>LinearToDb</c> before being pushed to
/// <c>AudioServer.SetBusVolumeDb</c>. Changes are immediate
/// and independent across the three buses.
/// </summary>
void SetVolume(MixerType type, float volume);
/// <summary>
/// Mutes the bus by forcing its volume to -80 dB when
/// <paramref name="enabled"/> is <c>false</c>. When
/// <paramref name="enabled"/> is <c>true</c> the method
/// is a no-op — normal volume is restored by a subsequent
/// <c>SetVolume</c> or settings poll.
/// </summary>
void SetEnabled(MixerType type, bool enabled);
}
}
+1
View File
@@ -0,0 +1 @@
uid://ckvpr338ndwqw
+17
View File
@@ -0,0 +1,17 @@
namespace Cthangover.Core.Audio
{
/// <summary>
/// Identifies one of the three hardware audio buses. Each bus has
/// independent volume and mute state, allowing e.g. music to be muted
/// while SFX and ambient continue playing.
/// </summary>
public enum MixerType
{
/// <summary>Bus for short sound effects (SFX).</summary>
Sounds,
/// <summary>Bus for background music.</summary>
Musics,
/// <summary>Bus for ambient loops (dynamically crossfaded).</summary>
Ambient
}
}
+1
View File
@@ -0,0 +1 @@
uid://b5wifadjjdbe2
+443
View File
@@ -0,0 +1,443 @@
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Core.Audio
{
/// <summary>
/// Autonomous music player that drives playlist auto-advance and
/// scene-aware track switching. It finds the AudioService's MusicPlayer
/// via the scene tree (not injected), initialises playlists lazily,
/// and advances to a random track when playback ends.
/// The core complexity is the Combat ↔ Ambient handoff:
/// when entering combat, the current ambient track and time are saved;
/// when leaving combat, they're restored — creating a seamless
/// interruption model. FadeMusic uses a Tween with a 1s delay
/// followed by a 6s volume ramp.
/// Disabling auto-play preserves the last track/time; re-enabling
/// resumes from the saved point.
/// </summary>
public partial class MusicPlayerBehaviour : Node
{
private AudioStreamPlayer audioPlayer;
private readonly PlaylistContext playlistContext = new();
/// <summary>
/// Top-level toggle for the entire auto-advance state machine.
/// When <c>false</c>, <c>NextSound</c> is skipped, no new
/// tracks are selected, and <c>UpdateMusicType</c> does not
/// trigger a switch. Set by <c>EnabledAutoPlay</c> /
/// <c>DisabledAutoPlay</c> and also forced to <c>false</c>
/// when a playlist has no tracks.
/// </summary>
public bool IsCanAutoPlay { get; set; } = true;
private Tween fadeTween;
private bool playlistsInited;
private double autoAdvanceCooldown;
public override void _Ready()
{
AddToGroup("music_player");
TryFindAudioPlayer();
}
public override void _Process(double delta)
{
if (audioPlayer == null || !IsInstanceValid(audioPlayer))
{
TryFindAudioPlayer();
return;
}
if (!playlistsInited)
{
// During the launcher boot the mod registry is being populated on a
// background thread; reading Mods from the main thread would race
// with that write. Defer playlist init until mods are ready — the
// playlist is re-initialised on scene transitions anyway.
if (!Cthangover.Core.Mods.ModManager.Instance.IsInitialized)
return;
var sceneMgr = GetNodeOrNull<Scenes.SceneManager>("/root/SceneManager");
var sceneName = sceneMgr?.CurrentSceneName ?? GameData.Instance?.Runtime?.CurrentScene.ToString() ?? "MainMenu";
InitPlaylists(sceneName);
playlistsInited = true;
autoAdvanceCooldown = Time.GetTicksUsec() / 1_000_000.0 + 0.5;
}
if (!IsCanAutoPlay)
return;
double now = Time.GetTicksUsec() / 1_000_000.0;
if (now > autoAdvanceCooldown && !audioPlayer.Playing && !audioPlayer.StreamPaused)
{
NextSound();
autoAdvanceCooldown = now + 0.5;
}
}
/// <summary>
/// Re-enables auto-play after it was disabled. Stops current
/// playback and, if a <c>LastMusicName</c> is saved, restores
/// that track from <c>MusicFactory</c> — effectively resuming
/// the previously-interrupted song. If no track is saved the
/// auto-advance loop in <c>_Process</c> will pick the next
/// random track within 0.5s.
/// </summary>
public void EnabledAutoPlay()
{
GameLogger.Log("AUDIO", "Autoplay enabled");
IsCanAutoPlay = true;
StopMusic();
if (playlistContext.LastMusicName != null && audioPlayer != null)
{
audioPlayer.Stream = MusicFactory.Instance.Get(playlistContext.LastMusicName);
audioPlayer.Play();
}
}
/// <summary>
/// Disables auto-play and saves the current playback position
/// into <c>PlaylistContext.LastMusicTime</c> so it can be
/// restored later via <c>EnabledAutoPlay</c>. Stops playback
/// immediately after saving.
/// </summary>
public void DisabledAutoPlay()
{
GameLogger.Log("AUDIO", "Autoplay disabled");
IsCanAutoPlay = false;
if (playlistContext.LastMusicName != null && audioPlayer != null && audioPlayer.Playing)
playlistContext.LastMusicTime = audioPlayer.GetPlaybackPosition();
StopMusic();
}
/// <summary>
/// Overload that delegates to
/// <c>UpdateMusicType(scene.ToString())</c>.
/// </summary>
public void UpdateMusicType(GodotSceneType scene)
{
UpdateMusicType(scene.ToString());
}
/// <summary>
/// Scene-transition entry point that orchestrates the
/// Combat ↔ Ambient music handoff. Re-initialises the
/// playlist for the new scene and determines the appropriate
/// <see cref="MusicType"/>:
/// <list type="bullet">
/// <item>If the type is unchanged and the current track exists
/// in the new playlist, playback continues uninterrupted.</item>
/// <item>If the type is unchanged but the track is absent from
/// the new playlist, a new random track is picked.</item>
/// <item>When transitioning <b>Ambient → Combat</b>: the
/// current ambient track name and playback position are saved
/// to <c>PlaylistContext</c> so they can be restored after
/// combat ends.</item>
/// <item>When transitioning <b>Combat → Ambient</b>: the
/// saved ambient state is restored. If the saved time is past
/// 0.5s, <c>OggPacketParser.CreateTrimmedStream</c> is used to
/// create a truncated OGG stream starting from the saved
/// position — this avoids the O(N) seek penalty of
/// <c>Play(fromPosition)</c> on large OGG files.</item>
/// </list>
/// When auto-play is disabled the entire method is a no-op
/// except for playlist initialisation.
/// </summary>
public void UpdateMusicType(string sceneName)
{
var previousType = playlistContext.LastMusicType;
InitPlaylists(sceneName);
if (!IsCanAutoPlay)
return;
var newType = string.Equals(sceneName, "Battle", System.StringComparison.OrdinalIgnoreCase) ? MusicType.Combat : MusicType.Ambient;
GameLogger.Log("AUDIO", $"Scene '{sceneName}' -> music type {newType} (was {previousType})");
if (newType == previousType)
{
if (!string.IsNullOrEmpty(playlistContext.LastMusicName))
{
var dict = playlistContext.Playlist?.Musics;
if (dict != null && dict.TryGetValue(newType, out var list) && list != null && list.Contains(playlistContext.LastMusicName))
{
GameLogger.Log("AUDIO", $"Keeping current track '{playlistContext.LastMusicName}' (found in scene playlist)");
return;
}
}
GameLogger.Log("AUDIO", $"Current track '{playlistContext.LastMusicName}' not in scene playlist, switching");
NextSound();
return;
}
if (newType == MusicType.Combat && previousType == MusicType.Ambient)
{
if (audioPlayer != null && audioPlayer.Playing)
{
playlistContext.SavedAmbientMusicName = playlistContext.LastMusicName;
playlistContext.SavedAmbientMusicTime = audioPlayer.GetPlaybackPosition();
GameLogger.Log("AUDIO", $"Saved ambient state: '{playlistContext.SavedAmbientMusicName}' at {playlistContext.SavedAmbientMusicTime:F1}s");
}
playlistContext.LastMusicType = newType;
StopMusic();
NextSound();
return;
}
if (newType == MusicType.Ambient && previousType == MusicType.Combat)
{
StopMusic();
playlistContext.LastMusicType = newType;
if (!string.IsNullOrEmpty(playlistContext.SavedAmbientMusicName) && audioPlayer != null)
{
var stream = MusicFactory.Instance.Get(playlistContext.SavedAmbientMusicName);
if (stream != null)
{
var savedTime = (double)playlistContext.SavedAmbientMusicTime;
GameLogger.Log("AUDIO", $"Restoring ambient: '{playlistContext.SavedAmbientMusicName}' was at {savedTime:F1}s");
if (savedTime > 0.5f)
{
var trimmed = OggPacketParser.CreateTrimmedStream(stream, savedTime);
if (trimmed != null)
{
stream = trimmed;
GameLogger.Log("AUDIO", $"Trimmed stream created — playing from 0 (effective start {savedTime:F1}s)");
}
}
audioPlayer.Stream = stream;
playlistContext.LastMusicName = playlistContext.SavedAmbientMusicName;
audioPlayer.Play();
playlistContext.SavedAmbientMusicName = null;
playlistContext.SavedAmbientMusicTime = 0;
autoAdvanceCooldown = Time.GetTicksUsec() / 1_000_000.0 + 0.5;
return;
}
GameLogger.Log("AUDIO", $"Restore failed: stream not found for '{playlistContext.SavedAmbientMusicName}'", LogLevel.Error);
playlistContext.SavedAmbientMusicName = null;
playlistContext.SavedAmbientMusicTime = 0;
}
else
{
GameLogger.Log("AUDIO", "No saved ambient state to restore");
}
NextSound();
return;
}
playlistContext.LastMusicType = newType;
NextSound();
}
/// <summary>
/// Resolves the music type for a scene enum value: returns
/// <c>MusicType.Combat</c> for <c>GodotSceneType.Battle</c>,
/// otherwise <c>MusicType.Ambient</c>. Used by external systems
/// that need to know the music category without entering the
/// full scene-transition flow.
/// </summary>
public MusicType GetMusicType(GodotSceneType scene)
{
return scene == GodotSceneType.Battle ? MusicType.Combat : MusicType.Ambient;
}
/// <summary>
/// Picks a random track from the current playlist's
/// <c>LastMusicType</c> bucket, avoiding the immediately
/// previous track when the bucket has more than one entry.
/// Stops the current player and starts the new track via
/// <c>PlayMusic</c>. Skips silently when auto-play is
/// disabled or the playlist is empty.
/// </summary>
public void NextSound()
{
if (!IsCanAutoPlay)
return;
var dict = playlistContext.Playlist?.Musics;
if (dict == null || !dict.TryGetValue(playlistContext.LastMusicType, out var list) || Lists.IsEmpty(list))
return;
int iteration = 0;
for (;;)
{
int index = (int)(GD.Randi() % (uint)list.Count);
var nextMusic = list[index];
if (list.Count == 1 || playlistContext.LastMusicName != nextMusic)
{
GameLogger.Log("AUDIO", $"NextSound -> '{nextMusic}' (type={playlistContext.LastMusicType}, scene={playlistContext.Playlist?.Scene})");
StopMusic();
PlayMusic(nextMusic);
break;
}
if (iteration++ > 10)
break;
}
}
/// <summary>
/// Overload that delegates to
/// <c>InitPlaylists(scene.ToString())</c>.
/// </summary>
public void InitPlaylists(GodotSceneType scene)
{
InitPlaylists(scene.ToString());
}
/// <summary>
/// Builds the playlist for a scene via
/// <c>PlaylistFactory.CreatePlaylist</c>. If the playlist for
/// the same scene is already loaded the call is a no-op. Forces
/// <c>LastMusicType</c> from <c>Force</c> to <c>Ambient</c> so
/// transient forced tracks don't persist across scenes. If the
/// resulting playlist has no tracks, disables auto-play
/// entirely.
/// </summary>
public void InitPlaylists(string sceneName)
{
if (playlistContext.Playlist != null && playlistContext.Playlist.Scene == sceneName)
return;
playlistContext.Playlist = PlaylistFactory.Instance.CreatePlaylist(sceneName);
if (playlistContext.LastMusicType == MusicType.Force)
playlistContext.LastMusicType = MusicType.Ambient;
var trackCount = playlistContext.Playlist.Musics?.Count ?? 0;
GameLogger.Log("AUDIO", $"InitPlaylists scene='{sceneName}' tracks={trackCount} autoplay={IsCanAutoPlay}");
if (playlistContext.Playlist.Musics == null || playlistContext.Playlist.Musics.Count == 0)
IsCanAutoPlay = false;
}
/// <summary>
/// Manually plays a specific track with an explicit
/// <c>AudioStream</c>. Updates <c>LastMusicName</c> and resets
/// the auto-advance cooldown to prevent an immediate skip.
/// The <paramref name="isLooped"/> parameter is accepted but
/// currently unused — OGG streams are expected to be
/// non-looping and auto-advance handles the next-track
/// selection.
/// </summary>
public void PlayMusic(string name, AudioStream music, bool isLooped = false)
{
if (audioPlayer == null || !IsInstanceValid(audioPlayer))
TryFindAudioPlayer();
if (audioPlayer == null)
return;
GameLogger.Log("AUDIO", $"PlayMusic '{name}'");
playlistContext.LastMusicName = name;
audioPlayer.Stream = music;
audioPlayer.Play();
autoAdvanceCooldown = Time.GetTicksUsec() / 1_000_000.0 + 0.5;
}
/// <summary>
/// Plays a track by name, resolving the stream from
/// <c>MusicFactory</c>. Delegates to the
/// <c>PlayMusic(name, stream, isLooped)</c> overload.
/// </summary>
public void PlayMusic(string name, bool isLooped = false)
{
PlayMusic(name, MusicFactory.Instance.Get(name), isLooped);
}
/// <summary>
/// Resumes the currently-loaded stream on the audio player.
/// Unlike the named overloads, does not change the stream
/// or <c>LastMusicName</c>. Resets the auto-advance cooldown
/// so the just-resumed track isn't replaced immediately.
/// </summary>
public void PlayMusic()
{
audioPlayer?.Play();
autoAdvanceCooldown = Time.GetTicksUsec() / 1_000_000.0 + 0.5;
}
/// <summary>
/// Pauses the music player in-place via <c>StreamPaused</c>.
/// Unlike <c>StopMusic</c>, the stream stays loaded and
/// <c>PlayMusic()</c> will resume from the paused sample.
/// </summary>
public void PauseMusic()
{
GameLogger.Log("AUDIO", "PauseMusic");
if (audioPlayer != null)
audioPlayer.StreamPaused = true;
}
/// <summary>
/// Immediately stops playback on the audio player. The stream
/// reference is preserved; a subsequent <c>PlayMusic()</c>
/// would restart the same track from the beginning.
/// <c>LastMusicName</c> is not cleared, so <c>EnabledAutoPlay</c>
/// can restore it from the factory.
/// </summary>
public void StopMusic()
{
GameLogger.Log("AUDIO", "StopMusic");
audioPlayer?.Stop();
}
/// <summary>
/// Fades the music bus volume from 1 to 0 over 6 seconds after
/// a 1-second delay, then stops playback and resets the bus to
/// full volume. The fade is implemented via a Godot <c>Tween</c>
/// that calls <c>AudioService.SetVolume</c> each frame. If a
/// previous fade is still running it is killed before starting the
/// new one. The final reset to volume=1 is necessary because the
/// settings poll would otherwise keep the bus at zero until the
/// next <c>ApplySettings</c> cycle.
/// </summary>
public void FadeMusic()
{
GameLogger.Log("AUDIO", "FadeMusic start (1s delay + 6s fade)");
fadeTween?.Kill();
fadeTween = CreateTween();
fadeTween.TweenInterval(1.0);
fadeTween.TweenMethod(
Callable.From<float>(vol =>
{
var service = GetNodeOrNull<AudioService>("/root/AudioService");
service?.SetVolume(MixerType.Musics, vol);
}),
1f, 0f, 6.0
);
fadeTween.TweenCallback(Callable.From(() =>
{
StopMusic();
var service = GetNodeOrNull<AudioService>("/root/AudioService");
service?.SetVolume(MixerType.Musics, 1f);
}));
}
private void TryFindAudioPlayer()
{
var service = GetNodeOrNull<AudioService>("/root/AudioService");
if (service != null)
{
audioPlayer = service.GetNodeOrNull<AudioStreamPlayer>("MusicPlayer");
}
}
}
}
+1
View File
@@ -0,0 +1 @@
uid://clpaindejc5fa
+62
View File
@@ -0,0 +1,62 @@
using System;
namespace Cthangover.Core.Audio
{
/// <summary>
/// Serializable scene-to-music mapping with an explicit Index for
/// ordering. Defaults to <c>Ambient</c> type. Used for serialised
/// music configs that need a deterministic per-scene track order
/// rather than random selection.
/// </summary>
[Serializable]
public class MusicSceneItem
{
/// <summary>
/// Asset name used as a key in <c>MusicFactory</c> lookups.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Zero-based ordering index within the scene's track list.
/// Used for deterministic playback order rather than random
/// selection.
/// </summary>
public int Index { get; set; }
/// <summary>
/// The music category for this track. Defaults to
/// <see cref="MusicType.Ambient"/>.
/// </summary>
public MusicType Type { get; set; } = MusicType.Ambient;
/// <summary>
/// Parameterless constructor for serialisation. Initialises
/// <c>Name</c> to <see cref="string.Empty"/>.
/// </summary>
public MusicSceneItem()
{
Name = string.Empty;
}
/// <summary>
/// Constructs an entry with a name, an ordering index, and an
/// optional music type (defaults to <c>Ambient</c>).
/// </summary>
public MusicSceneItem(string name, int index, MusicType type = MusicType.Ambient)
{
Name = name;
Index = index;
Type = type;
}
/// <summary>
/// Returns the item name for debug/log display.
/// </summary>
public override string ToString()
{
return $"MusicSceneItem: Name={Name}";
}
}
}
+1
View File
@@ -0,0 +1 @@
uid://f6u7fic48uqg
+31
View File
@@ -0,0 +1,31 @@
namespace Cthangover.Core.Audio
{
/// <summary>
/// Tags music tracks by gameplay context. <c>Force</c> is a transient
/// signal — Normalized to <c>Ambient</c> during playlist init.
/// The <c>Combat</c> ↔ <c>Ambient</c> transition is stateful:
/// switching to Combat saves the ambient track and playback position
/// so it can resume seamlessly when combat ends.
/// </summary>
public enum MusicType
{
/// <summary>
/// Transient signal used to force a specific track. Normalised to
/// <c>Ambient</c> during playlist initialisation so it never
/// persists across scene transitions.
/// </summary>
Force,
/// <summary>
/// Battle music. Transitioning to Combat saves the current
/// Ambient track and position for later restoration.
/// </summary>
Combat,
/// <summary>
/// Background exploration music. The default type; restored
/// with the saved track when returning from Combat.
/// </summary>
Ambient
}
}
+1
View File
@@ -0,0 +1 @@
uid://buck210o3v7ga
+353
View File
@@ -0,0 +1,353 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Core.Audio
{
/// <summary>
/// Low-level Ogg Vorbis page parser. Reads raw OGG container bytes and
/// produces an <c>OggPacketSequence</c> that Godot's
/// <c>AudioStreamOggVorbis</c> can consume directly.
/// Handles multi-page continuation (a packet split across page
/// boundaries via the continuation flag), extracts granule positions
/// for accurate seeking, and reads the Vorbis identification header
/// to recover the sampling rate. Falls back to 44100 Hz if the header
/// is absent or unparseable. Returns <c>null</c> on any corruption,
/// logging the offset of the invalid page.
/// </summary>
public static class OggPacketParser
{
private const int OGG_PAGE_HEADER_SIZE = 27;
/// <summary>
/// Creates a new <c>AudioStreamOggVorbis</c> with audio data
/// starting from the given time. Header packets (identification,
/// comment, setup) are preserved. Granule positions are normalised
/// so <c>Play(0)</c> starts at the target time without the O(N)
/// seek penalty that <c>Play(fromPosition)</c> incurs on a full
/// OGG stream.
/// Returns <c>null</c> if the stream cannot be trimmed (e.g. not
/// an OGG, too short, or corrupted).
/// </summary>
public static AudioStreamOggVorbis CreateTrimmedStream(
AudioStream source, double startTimeSeconds)
{
if (source is not AudioStreamOggVorbis ogg || ogg.PacketSequence == null)
return null;
var seq = ogg.PacketSequence;
var packetData = seq.PacketData;
var granulePositions = seq.GranulePositions;
var samplingRate = seq.SamplingRate;
const int HEADER_COUNT = 3;
if (packetData == null || packetData.Count <= HEADER_COUNT)
return null;
if (granulePositions == null || granulePositions.Length < packetData.Count)
return null;
if (startTimeSeconds <= 0 || samplingRate <= 0)
return null;
int startPacketIndex = -1;
long baseGranule = 0;
for (int i = HEADER_COUNT; i < packetData.Count; i++)
{
if (i >= granulePositions.Length)
break;
long granule = granulePositions[i];
if (granule < 0)
continue;
double timeSeconds = (double)granule / samplingRate;
if (timeSeconds >= startTimeSeconds)
{
startPacketIndex = i;
baseGranule = granule;
break;
}
}
if (startPacketIndex < 0)
return null;
var trimmedPacketData = new Godot.Collections.Array<Godot.Collections.Array>();
var trimmedGranulePositions = new long[packetData.Count - startPacketIndex + HEADER_COUNT];
for (int i = 0; i < HEADER_COUNT; i++)
{
trimmedPacketData.Add(packetData[i]);
trimmedGranulePositions[i] = 0;
}
for (int i = startPacketIndex; i < packetData.Count; i++)
{
int trimmedIdx = HEADER_COUNT + (i - startPacketIndex);
trimmedPacketData.Add(packetData[i]);
if (i < granulePositions.Length)
{
long granule = granulePositions[i];
trimmedGranulePositions[trimmedIdx] = granule >= 0
? granule - baseGranule
: -1;
}
}
var trimmedSeq = new OggPacketSequence();
trimmedSeq.PacketData = trimmedPacketData;
trimmedSeq.GranulePositions = trimmedGranulePositions;
trimmedSeq.SamplingRate = samplingRate;
var trimmedStream = new AudioStreamOggVorbis();
trimmedStream.PacketSequence = trimmedSeq;
return trimmedStream;
}
/// <summary>
/// Parses raw OGG container bytes into a Godot
/// <c>OggPacketSequence</c> ready for consumption by
/// <c>AudioStreamOggVorbis</c>. Walks the page structure:
/// <list type="bullet">
/// <item>Validates the "OggS" magic at each page header.</item>
/// <item>Reads the segment table to split pages into individual
/// packets, correctly reassembling packets that span page
/// boundaries via the continuation flag.</item>
/// <item>Assigns the page's granule position to each packet
/// extracted from that page, preserving seek metadata.</item>
/// <item>Extracts the sampling rate from the first packet's
/// Vorbis identification header; falls back to 44100 Hz if
/// the header is missing or malformed.</item>
/// <item>Any unflushed pending packet at EOF is appended as
/// a final packet.</item>
/// </list>
/// Returns <c>null</c> on corruption (bad magic, segment table
/// overflow) or if no packets were extracted, logging the
/// error with the offset for debugging.
/// </summary>
public static OggPacketSequence CreateFromOggBytes(byte[] data)
{
if (data == null || data.Length < OGG_PAGE_HEADER_SIZE)
{
GameLogger.Log("AUDIO", "OggPacketParser: data is null or too short", LogLevel.Error);
return null;
}
try
{
var packets = new List<byte[]>();
var granulePositions = new List<long>();
int offset = 0;
byte[] pendingPacket = null;
float samplingRate = 0;
bool gotSamplingRate = false;
while (offset < data.Length)
{
if (offset + OGG_PAGE_HEADER_SIZE > data.Length)
break;
if (!IsOggPage(data, offset))
{
GameLogger.Log("AUDIO", $"OggPacketParser: invalid page marker at offset {offset}", LogLevel.Error);
return null;
}
bool isContinued = (data[offset + 5] & 0x01) != 0;
long granulePos = ReadInt64LE(data, offset + 6);
int segmentCount = data[offset + 26];
int segTableOffset = offset + OGG_PAGE_HEADER_SIZE;
if (segTableOffset + segmentCount > data.Length)
{
GameLogger.Log("AUDIO", $"OggPacketParser: segment table exceeds data at offset {offset}", LogLevel.Error);
return null;
}
int segDataOffset = segTableOffset + segmentCount;
int segDataEnd = CalculateSegmentDataEnd(data, offset, segmentCount);
int packetsBefore = packets.Count;
packets.AddRange(ExtractPackets(
data, segTableOffset, segmentCount, segDataOffset,
ref pendingPacket, isContinued));
int packetsAdded = packets.Count - packetsBefore;
for (int i = 0; i < packetsAdded; i++)
granulePositions.Add(granulePos);
if (!gotSamplingRate && packets.Count >= 1)
{
samplingRate = ExtractSamplingRate(packets[0]);
gotSamplingRate = true;
}
offset = segDataEnd;
}
if (pendingPacket != null && pendingPacket.Length > 0)
{
packets.Add(pendingPacket);
granulePositions.Add(granulePositions.Count > 0
? granulePositions[granulePositions.Count - 1]
: 0);
}
if (packets.Count == 0)
{
GameLogger.Log("AUDIO", "OggPacketParser: no packets extracted", LogLevel.Error);
return null;
}
if (granulePositions.Count == 0)
{
GameLogger.Log("AUDIO", "OggPacketParser: no granule positions extracted", LogLevel.Error);
return null;
}
return BuildPacketSequence(packets, granulePositions, samplingRate);
}
catch (Exception ex)
{
GameLogger.Log("AUDIO", $"OggPacketParser: error: {ex.Message}", LogLevel.Error);
return null;
}
}
private static bool IsOggPage(byte[] data, int offset)
{
return data[offset] == 0x4F && data[offset + 1] == 0x67
&& data[offset + 2] == 0x67 && data[offset + 3] == 0x53;
}
private static long ReadInt64LE(byte[] data, int offset)
{
return (long)data[offset]
| ((long)data[offset + 1] << 8)
| ((long)data[offset + 2] << 16)
| ((long)data[offset + 3] << 24)
| ((long)data[offset + 4] << 32)
| ((long)data[offset + 5] << 40)
| ((long)data[offset + 6] << 48)
| ((long)data[offset + 7] << 56);
}
private static int CalculateSegmentDataEnd(byte[] data, int pageOffset, int segmentCount)
{
int segTableOffset = pageOffset + OGG_PAGE_HEADER_SIZE;
int end = segTableOffset + segmentCount;
for (int i = 0; i < segmentCount; i++)
end += data[segTableOffset + i];
return end;
}
private static float ExtractSamplingRate(byte[] firstPacket)
{
if (firstPacket == null || firstPacket.Length < 16)
return 44100f;
if (firstPacket[0] == 0x01
&& firstPacket[1] == 'v'
&& firstPacket[2] == 'o'
&& firstPacket[3] == 'r'
&& firstPacket[4] == 'b'
&& firstPacket[5] == 'i'
&& firstPacket[6] == 's')
{
return BitConverter.ToInt32(firstPacket, 12);
}
return 44100f;
}
private static List<byte[]> ExtractPackets(
byte[] data, int segTableOffset, int segmentCount,
int segDataOffset, ref byte[] pendingPacket, bool isContinued)
{
var packets = new List<byte[]>();
if (!isContinued && pendingPacket != null && pendingPacket.Length > 0)
{
packets.Add(pendingPacket);
}
int currentOffset = segDataOffset;
var currentPacketChunks = new List<byte[]>();
if (isContinued && pendingPacket != null)
{
currentPacketChunks.Add(pendingPacket);
}
for (int i = 0; i < segmentCount; i++)
{
int segSize = data[segTableOffset + i];
if (currentOffset + segSize > data.Length)
break;
if (segSize > 0)
{
var chunk = new byte[segSize];
Buffer.BlockCopy(data, currentOffset, chunk, 0, segSize);
currentPacketChunks.Add(chunk);
currentOffset += segSize;
}
bool isLastSegment = (i == segmentCount - 1);
bool packetComplete = segSize < 255 || isLastSegment;
if (packetComplete && currentPacketChunks.Count > 0)
{
packets.Add(ConcatChunks(currentPacketChunks));
currentPacketChunks.Clear();
}
}
pendingPacket = currentPacketChunks.Count > 0
? ConcatChunks(currentPacketChunks)
: null;
return packets;
}
private static byte[] ConcatChunks(List<byte[]> chunks)
{
if (chunks.Count == 1)
return chunks[0];
int total = 0;
foreach (var c in chunks)
total += c.Length;
var result = new byte[total];
int offset = 0;
foreach (var c in chunks)
{
Buffer.BlockCopy(c, 0, result, offset, c.Length);
offset += c.Length;
}
return result;
}
private static OggPacketSequence BuildPacketSequence(
List<byte[]> packets, List<long> granulePositions, float samplingRate)
{
var packetArray = new Godot.Collections.Array<Godot.Collections.Array>();
foreach (var packet in packets)
{
var inner = new Godot.Collections.Array();
inner.Add(packet);
packetArray.Add(inner);
}
var packetSequence = new OggPacketSequence();
packetSequence.PacketData = packetArray;
packetSequence.GranulePositions = granulePositions.ToArray();
packetSequence.SamplingRate = samplingRate;
return packetSequence;
}
}
}
+1
View File
@@ -0,0 +1 @@
uid://dd8fqikgkkpov
+31
View File
@@ -0,0 +1,31 @@
using System.Collections.Generic;
namespace Cthangover.Core.Audio
{
/// <summary>
/// Runtime playlist model: a scene name mapped to lists of track names
/// grouped by MusicType. Built by PlaylistFactory from the flatter
/// <see cref="PlaylistData"/> JSON format.
/// </summary>
public class Playlist
{
/// <summary>
/// The scene name this playlist belongs to (e.g. "Tavern", "Battle").
/// Used as the lookup key in <c>PlaylistContext</c> and
/// <c>MusicPlayerBehaviour</c> to avoid redundant factory calls
/// when the scene hasn't changed.
/// </summary>
public string Scene { get; set; }
/// <summary>
/// Tracks grouped by <see cref="MusicType"/>. The dictionary
/// provides O(1) access to the track list for the current music
/// type during auto-advance. An inner <c>List&lt;string&gt;</c>
/// is used (rather than a set) so duplicate entries are
/// preserved and random selection is uniform.
/// </summary>
public IDictionary<MusicType, List<string>> Musics { get; set; }
}
}
+1
View File
@@ -0,0 +1 @@
uid://dsl33e0umxjub
+56
View File
@@ -0,0 +1,56 @@
namespace Cthangover.Core.Audio
{
/// <summary>
/// Mutable playback state carried across track switches and scene
/// transitions. Holds the active Playlist, the last played track/type/time,
/// and the saved ambient state (<c>SavedAmbientMusicName</c> /
/// <c>SavedAmbientMusicTime</c>) used to restore ambient music after
/// combat interruptions.
/// </summary>
public class PlaylistContext
{
/// <summary>
/// The currently active playlist, resolved by scene name.
/// </summary>
public Playlist Playlist { get; set; }
/// <summary>
/// The <see cref="MusicType"/> of the last played (or
/// currently playing) track. Initialised to <c>Force</c>
/// and normalised to <c>Ambient</c> on the first playlist
/// init.
/// </summary>
public MusicType LastMusicType { get; set; }
/// <summary>
/// Asset name of the last track. Saved across stop/start
/// cycles so <c>EnabledAutoPlay</c> can restore it from
/// the factory.
/// </summary>
public string LastMusicName { get; set; }
/// <summary>
/// Playback position of the last track, in seconds. Captured
/// when auto-play is disabled so the track can resume near
/// where it left off.
/// </summary>
public float LastMusicTime { get; set; }
/// <summary>
/// Ambient track name saved when entering combat. Restored
/// on return to ambient so the player hears the same
/// background music that was interrupted.
/// </summary>
public string SavedAmbientMusicName { get; set; }
/// <summary>
/// Ambient track position (seconds) saved alongside
/// <see cref="SavedAmbientMusicName"/>. Used to create a
/// trimmed OGG stream via <c>OggPacketParser</c> when
/// the saved time exceeds 0.5s.
/// </summary>
public float SavedAmbientMusicTime { get; set; }
}
}
+1
View File
@@ -0,0 +1 @@
uid://w766wlr8cbhk
+25
View File
@@ -0,0 +1,25 @@
using System.Collections.Generic;
namespace Cthangover.Core.Audio
{
/// <summary>
/// JSON deserialization shape for playlist config files. Flat: one scene
/// with a list of <see cref="PlaylistMusicEntry"/> objects.
/// The factory resolves this into a <see cref="Playlist"/> where
/// entries are already grouped by MusicType for O(1) lookup.
/// </summary>
public class PlaylistData
{
/// <summary>
/// The scene name this config entry targets.
/// </summary>
public string Scene { get; set; }
/// <summary>
/// Flat list of per-type entries. The factory groups these
/// into the dictionary form in <see cref="Playlist.Musics"/>
/// for efficient runtime lookup.
/// </summary>
public List<PlaylistMusicEntry> Musics { get; set; }
}
}
+1
View File
@@ -0,0 +1 @@
uid://crvtaessfkeh
+28
View File
@@ -0,0 +1,28 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Cthangover.Core.Audio
{
/// <summary>
/// A single playlist entry in JSON — pairs a MusicType with the list of
/// asset names that belong to it. <c>JsonStringEnumConverter</c> is
/// used so the type is serialised as a string rather than an integer.
/// </summary>
public class PlaylistMusicEntry
{
/// <summary>
/// The music category for this group of tracks. Serialised as a
/// string (e.g. "Combat", "Ambient") via
/// <c>JsonStringEnumConverter</c> so JSON configs remain
/// human-readable.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public MusicType MusicType { get; set; }
/// <summary>
/// Asset names belonging to this <see cref="MusicType"/> group.
/// These are resolved by <c>MusicFactory</c> at playback time.
/// </summary>
public List<string> MusicNames { get; set; }
}
}
+1
View File
@@ -0,0 +1 @@
uid://qastbeowk183
+28
View File
@@ -0,0 +1,28 @@
namespace Cthangover.Core.Audio
{
/// <summary>
/// Sound category used as a pooling key. Each type gets its own
/// AudioStreamPlayer, so a <c>CardEffect</c> sound cuts the previous
/// card effect while a <c>UI</c> sound plays independently.
/// <c>Timed</c> is intended for short-lived, one-shot events.
/// </summary>
public enum SoundType
{
/// <summary>Short-lived one-shot events; cut off by the next Timed sound.</summary>
Timed,
/// <summary>Long-running background effects; independent pool.</summary>
Background,
/// <summary>Foreground sounds that should not overlap with Background.</summary>
Foreground,
/// <summary>Interface clicks/hovers; isolated from game-world sounds.</summary>
UI,
/// <summary>Notification chimes; separate pool to avoid interrupting UI.</summary>
Notification,
/// <summary>Card ability VFX sounds; cuts previous card effects.</summary>
CardEffect,
/// <summary>Card action (play/discard) sounds; independent of CardEffect.</summary>
CardAction,
}
}
+1
View File
@@ -0,0 +1 @@
uid://dd8as4eex76ea
+342
View File
@@ -0,0 +1,342 @@
#if TOOLS
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Reflection;
using System.Threading.Tasks;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Core.Autotest
{
/// <summary>
/// Integration check for the Scene Builder tool chain, run inside a real
/// game process because most of the pipeline (mod assembly events, Timer
/// signals, Variant marshaling) cannot execute under plain unit tests.
/// Via reflection it instantiates the tools-mod SceneBuilderWindow, opens
/// cooking/cooking_panel.tscn through the real dropdowns, pushes an
/// inspector edit through the private write-back handler, waits out the
/// debounced auto-save and asserts the change reached the file on disk,
/// then restores the original file content. Exit code 0 = pass.
/// Launch: godot --path . scenes/test/scene_builder_autotest.tscn
/// </summary>
public partial class SceneBuilderAutotest : Node
{
private const string TargetModId = "cooking";
// Dropdown labels carry the file name incl. extension (Path.GetFileName).
private const string TargetSceneName = "cooking_panel.tscn";
private const string TargetSceneResPath = "res://mods/cooking/scenes/cooking_panel.tscn";
private const string WindowTypeName = "Cthangover.Core.UI.Tool.SceneBuilder.SceneBuilderWindow";
private const string ProbeKey = "anchor_right";
private const float ProbeValue = 0.75f;
private int _failures;
private bool _finished;
private string _originalText;
private Window _window;
public override void _Ready()
{
var watchdog = GetTree().CreateTimer(25d);
watchdog.Timeout += OnWatchdogFired;
Start();
}
private void OnWatchdogFired()
{
if (_finished)
return;
GameLogger.Log("SCENE_BUILDER_TEST", "watchdog fired — aborting", LogLevel.Error);
Finish();
}
private async void Start()
{
try
{
if (!PrepareOriginalFile())
{
Finish();
return;
}
var windowType = FindWindowType();
if (windowType == null)
{
Fail("tools assembly not loaded (window type not found)");
Finish();
return;
}
_window = (Window)Activator.CreateInstance(windowType);
// Root is busy inside its own child setup during _Ready — defer.
await NextFrame();
GetTree().Root.AddChild(_window);
await NextFrame();
if (!await SelectSceneInDropdowns(windowType))
{
Finish();
return;
}
var node = FindDescendantByName(GetModRoot(windowType), "BgImage");
if (node == null)
{
Fail("preview node 'BgImage' not found");
Finish();
return;
}
InvokeCommit(windowType, node);
var editor = (TextEdit)GetField(windowType, _window, "_tscnEditor");
var editorText = editor.Text;
Check(editorText != null && editorText.Contains(ProbeKey + " = " + ProbeLiteral()),
$"editor text patched with '{ProbeKey} = {ProbeLiteral()}'");
InvokeBatch(windowType, node);
await WaitSeconds(1.0d);
var onDisk = File.ReadAllText(ProjectSettings.GlobalizePath(TargetSceneResPath));
Check(onDisk.Contains(ProbeKey + " = " + ProbeLiteral()), "auto-save wrote inspector probe to disk");
Check(onDisk.Contains("offset_left = -5.0") && onDisk.Contains("offset_right = 5.0"),
"auto-save wrote overlay batch to disk");
// 4) node creation through the real UI handler (no selection → parent is scene root)
InvokeOnWindow(windowType, _window, "OnAddNodePressed");
var added = FindDescendantByName(GetModRoot(windowType), "Control");
Check(added != null, "live node 'Control' created under scene root");
var textAfterAdd = ((TextEdit)GetField(windowType, _window, "_tscnEditor")).Text;
Check(textAfterAdd.Contains("[node name=\"Control\" type=\"Control\" parent=\".\"]"),
"tscn section inserted for new node");
await WaitSeconds(1.0d);
var diskAfterAdd = File.ReadAllText(ProjectSettings.GlobalizePath(TargetSceneResPath));
Check(diskAfterAdd.Contains("[node name=\"Control\" type=\"Control\" parent=\".\"]"),
"auto-save wrote new node section to disk");
// 5) deletion of that node
SetField(windowType, _window, "_selectedNode", added);
InvokeOnWindow(windowType, _window, "OnDeleteNodePressed");
Check(FindDescendantByName(GetModRoot(windowType), "Control") == null, "live node removed from preview");
var textAfterDelete = ((TextEdit)GetField(windowType, _window, "_tscnEditor")).Text;
Check(!textAfterDelete.Contains("[node name=\"Control\" type=\"Control\" parent=\".\"]"),
"tscn section removed from editor text");
await WaitSeconds(1.0d);
var diskAfterDelete = File.ReadAllText(ProjectSettings.GlobalizePath(TargetSceneResPath));
Check(!diskAfterDelete.Contains("[node name=\"Control\" type=\"Control\" parent=\".\"]"),
"auto-save persisted the deletion to disk");
Finish();
}
catch (Exception ex)
{
Fail("exception: " + ex);
Finish();
}
}
private bool PrepareOriginalFile()
{
_originalText = File.ReadAllText(ProjectSettings.GlobalizePath(TargetSceneResPath));
if (_originalText.Contains(ProbeKey + " = " + ProbeLiteral()))
{
Fail($"file already contains probe value '{ProbeLiteral()}' — pick another key");
return false;
}
return true;
}
// Mirrors TscnValueFormatter.FormatFloat for the probe value: shortest
// float round-trip plus a guaranteed decimal point.
private static string ProbeLiteral()
{
var s = ProbeValue.ToString("R", CultureInfo.InvariantCulture);
if (!s.Contains('.'))
s += ".0";
return s;
}
private Type FindWindowType()
{
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
var t = asm.GetType(WindowTypeName);
if (t != null)
return t;
}
return null;
}
private async Task<bool> SelectSceneInDropdowns(Type windowType)
{
var modDropdown = (OptionButton)GetField(windowType, _window, "_modDropdown");
var sceneDropdown = (OptionButton)GetField(windowType, _window, "_sceneDropdown");
var modIdx = FindItem(modDropdown, TargetModId);
if (modIdx < 0)
{
Fail($"mod '{TargetModId}' missing from dropdown");
return false;
}
modDropdown.Selected = modIdx;
modDropdown.EmitSignal(OptionButton.SignalName.ItemSelected, (long)modIdx);
await NextFrame();
var sceneIdx = FindItem(sceneDropdown, TargetSceneName);
if (sceneIdx < 0)
{
Fail($"scene '{TargetSceneName}' missing from dropdown");
return false;
}
sceneDropdown.Selected = sceneIdx;
sceneDropdown.EmitSignal(OptionButton.SignalName.ItemSelected, (long)sceneIdx);
await NextFrame();
return true;
}
private Node GetModRoot(Type windowType)
{
var controller = GetField<object>(windowType, _window, "_controller");
return GetField<Node>(controller.GetType(), controller, "_modRoot");
}
private void InvokeCommit(Type windowType, Node node)
{
var mi = windowType.GetMethod("OnInspectorPropertyEdited", BindingFlags.NonPublic | BindingFlags.Instance);
mi.Invoke(_window, new object[] { node, ProbeKey, Variant.From(ProbeValue) });
}
private static void InvokeOnWindow(Type type, object instance, string methodName)
{
var mi = type.GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance);
if (mi == null)
throw new MissingMethodException(type.Name, methodName);
mi.Invoke(instance, Array.Empty<object>());
}
private static void SetField(Type type, object instance, string name, object value)
{
var current = type;
while (current != null)
{
var fi = current.GetField(name, BindingFlags.NonPublic | BindingFlags.Instance);
if (fi != null)
{
fi.SetValue(instance, value);
return;
}
current = current.BaseType;
}
throw new MissingFieldException(type.Name, name);
}
private void InvokeBatch(Type windowType, Node node)
{
var mi = windowType.GetMethod("OnOverlayBatchCommitted", BindingFlags.NonPublic | BindingFlags.Instance);
var batch = new List<KeyValuePair<string, Variant>>
{
new KeyValuePair<string, Variant>("layout_mode", Variant.From(1L)),
new KeyValuePair<string, Variant>("offset_left", Variant.From(-5f)),
new KeyValuePair<string, Variant>("offset_right", Variant.From(5f))
};
mi.Invoke(_window, new object[] { node, batch });
}
private static int FindItem(OptionButton dropdown, string text)
{
for (var i = 0; i < dropdown.ItemCount; i++)
{
if (dropdown.GetItemText(i) == text)
return i;
}
return -1;
}
private static Node FindDescendantByName(Node root, string name)
{
if (root == null)
return null;
if (root.Name == name)
return root;
foreach (var child in root.GetChildren())
{
var found = FindDescendantByName(child, name);
if (found != null)
return found;
}
return null;
}
private static object GetField(Type startType, object instance, string name)
{
var type = startType;
while (type != null)
{
var fi = type.GetField(name, BindingFlags.NonPublic | BindingFlags.Instance);
if (fi != null)
return fi.GetValue(instance);
type = type.BaseType;
}
throw new MissingFieldException(startType.Name, name);
}
private static T GetField<T>(Type startType, object instance, string name)
{
return (T)GetField(startType, instance, name);
}
private async Task NextFrame()
{
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
}
private async Task WaitSeconds(double seconds)
{
await ToSignal(GetTree().CreateTimer(seconds), SceneTreeTimer.SignalName.Timeout);
}
private void Check(bool condition, string label)
{
if (condition)
GameLogger.Log("SCENE_BUILDER_TEST", $"PASS {label}");
else
{
_failures++;
GameLogger.Log("SCENE_BUILDER_TEST", $"FAIL {label}", LogLevel.Error);
}
}
private void Fail(string message)
{
_failures++;
GameLogger.Log("SCENE_BUILDER_TEST", $"FAIL {message}", LogLevel.Error);
}
private void Finish()
{
if (_finished)
return;
_finished = true;
// Stop the debounced auto-save before restoring, otherwise it may
// rewrite edited content over the original file after we exit.
if (_window != null && GodotObject.IsInstanceValid(_window))
{
var timer = GetField(_window.GetType(), _window, "_autosaveTimer") as Timer;
timer?.Stop();
}
if (_originalText != null && File.Exists(ProjectSettings.GlobalizePath(TargetSceneResPath)))
File.WriteAllText(ProjectSettings.GlobalizePath(TargetSceneResPath), _originalText);
GameLogger.Log("SCENE_BUILDER_TEST",
_failures == 0 ? "RESULT: ALL PASSED" : $"RESULT: {_failures} FAILURES",
_failures == 0 ? LogLevel.Message : LogLevel.Error);
GetTree().Quit(_failures == 0 ? 0 : 1);
}
}
}
#endif
@@ -0,0 +1 @@
uid://dgk2tlx5kgffd
+127
View File
@@ -0,0 +1,127 @@
#if TOOLS
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Core.Autotest
{
/// <summary>
/// CLI-driven scene launcher that allows AI agents and automated tests to
/// run any game scene in isolation. Parses <c>--scene=</c> to determine the
/// target (a .tscn path or a logical scene name), instantiates it, attaches
/// a <see cref="DialogAutoDriver"/> for dialog auto-progression, and enforces
/// a configurable timeout to prevent hangs. Designed as the single entry point
/// for the "edit → build → launch → read logs → iterate" AI workflow.
/// </summary>
public partial class SceneSwitcher : Node
{
[Export] public float TimeoutSeconds { get; set; } = 60f;
private string _targetScene;
private string _choices;
private float _elapsed;
private bool _driverAttached;
public override void _Ready()
{
foreach (var arg in OS.GetCmdlineArgs())
{
if (arg.StartsWith("--scene="))
_targetScene = arg.Substring("--scene=".Length).Trim();
else if (arg.StartsWith("--choices="))
_choices = arg.Substring("--choices=".Length).Trim();
else if (arg.StartsWith("--timeout="))
{
if (float.TryParse(arg.Substring("--timeout=".Length), out var t) && t > 0)
TimeoutSeconds = t;
}
}
if (string.IsNullOrEmpty(_targetScene))
{
GameLogger.Log("TEST", "SceneSwitcher: --scene= not specified, quitting", LogLevel.Error);
GetTree().Quit(1);
return;
}
var s = GameData.Instance?.Settings;
if (s != null)
s.LauncherShown = true;
GameLogger.Log("TEST", $"SceneSwitcher: target='{_targetScene}', choices='{_choices}', timeout={TimeoutSeconds}s");
if (_targetScene.EndsWith(".tscn"))
LoadGodotScene(_targetScene);
else
LoadLogicalScene(_targetScene);
}
private void LoadGodotScene(string path)
{
var packed = GD.Load<PackedScene>(path);
if (packed == null)
{
GameLogger.Log("TEST", $"SceneSwitcher: failed to load PackedScene '{path}'", LogLevel.Error);
GetTree().Quit(1);
return;
}
AddChild(packed.Instantiate());
ScheduleDriverAttach();
}
private void LoadLogicalScene(string sceneName)
{
var baseScene = GD.Load<PackedScene>("res://scenes/ui/base_scene.tscn");
if (baseScene == null)
{
GameLogger.Log("TEST", "SceneSwitcher: failed to load base_scene.tscn", LogLevel.Error);
GetTree().Quit(1);
return;
}
AddChild(baseScene.Instantiate());
GetTree().CreateTimer(0.2f).Timeout += () =>
{
var sm = GetNodeOrNull<SceneManager>("/root/SceneManager");
if (sm != null)
{
GameLogger.Log("TEST", $"SceneSwitcher: switching to logical scene '{sceneName}'");
sm.SwitchScene(sceneName);
}
else
{
GameLogger.Log("TEST", "SceneSwitcher: SceneManager autoload not found", LogLevel.Error);
}
};
ScheduleDriverAttach();
}
private void ScheduleDriverAttach()
{
GetTree().CreateTimer(0.5f).Timeout += () =>
{
if (_driverAttached || !GodotObject.IsInstanceValid(this))
return;
_driverAttached = true;
var driver = new DialogAutoDriver();
AddChild(driver);
GameLogger.Log("TEST", "SceneSwitcher: DialogAutoDriver attached");
};
}
public override void _Process(double delta)
{
_elapsed += (float)delta;
if (_elapsed >= TimeoutSeconds)
{
GameLogger.Log("TEST", $"SceneSwitcher: timeout ({TimeoutSeconds}s) reached, quitting");
GetTree().Quit();
}
}
}
}
#endif
+1
View File
@@ -0,0 +1 @@
uid://mrtetivslsi5
+27
View File
@@ -0,0 +1,27 @@
using Cthangover.Core.Scenes;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Core.Autotest
{
/// <summary>
/// Test stub implementing <see cref="ITscnSceneEntryPoint"/> for integration testing.
/// Sets a static <see cref="WasCalled"/> flag when <see cref="OnSceneBuilt"/> is invoked,
/// allowing tests to verify the entry-point lifecycle.
/// </summary>
public partial class TscnTestEntryPoint : Node, ITscnSceneEntryPoint
{
public static bool WasCalled { get; set; }
public void OnSceneBuilt(Node sceneRoot)
{
WasCalled = true;
GameLogger.Log("TSCN_TEST", $"TscnTestEntryPoint.OnSceneBuilt: root='{sceneRoot?.Name}'");
}
public static void Reset()
{
WasCalled = false;
}
}
}
+1
View File
@@ -0,0 +1 @@
uid://dp2sne06s0als
+79
View File
@@ -0,0 +1,79 @@
using System.Collections.Generic;
using Cthangover.Core.Characters;
namespace Cthangover.Core.Battle.Actions
{
/// <summary>
/// Central action-execution dispatcher. Resolves an action ID to an
/// IActionExecutor in two tiers: first the active provider (set by
/// the current IBattleCore, allowing per-battle-engine overrides),
/// then a global fallback registry. This two-level lookup lets mods
/// supply custom executors for specific battle cores without replacing
/// the global ones. Execute returns ChangedAttributes to allow the
/// caller to inspect the result (success/failure, stat deltas).
/// </summary>
public class ActionExecutorHub
{
/// <summary>
/// Singleton hub instance. There is one global dispatcher shared
/// across all battle cores; cores swap providers via
/// <see cref="SetActiveProvider"/> when they activate.
/// </summary>
public static readonly ActionExecutorHub Instance = new();
private IActionExecutorProvider _activeProvider;
private readonly Dictionary<string, IActionExecutor> _globalExecutors = new();
/// <summary>
/// Installs a per-core executor provider. The active provider is
/// consulted before the global registry, allowing the current
/// battle core to override specific action handlers.
/// </summary>
public void SetActiveProvider(IActionExecutorProvider provider)
{
_activeProvider = provider;
}
/// <summary>
/// Registers a fallback executor in the global pool, keyed by
/// <see cref="IActionExecutor.ActionId"/>. Global executors are
/// used when no active provider supplies a match.
/// </summary>
public void RegisterGlobal(IActionExecutor executor)
{
if (executor != null && !string.IsNullOrEmpty(executor.ActionId))
_globalExecutors[executor.ActionId] = executor;
}
/// <summary>
/// Dispatches an action to the appropriate executor. Resolution
/// order: active provider first, then global registry. Returns
/// <c>Result = false</c> if no executor is found or if
/// <paramref name="action"/> is null.
/// </summary>
public ChangedAttributes Execute(ActionCharacter action, Character user, Character target)
{
if (action == null)
return new ChangedAttributes { Result = false };
var executor = Resolve(action.ID);
if (executor == null)
return new ChangedAttributes { Result = false };
return executor.Execute(action, user, target);
}
private IActionExecutor Resolve(string actionId)
{
if (_activeProvider != null)
{
var executor = _activeProvider.GetExecutor(actionId);
if (executor != null)
return executor;
}
_globalExecutors.TryGetValue(actionId, out var fallback);
return fallback;
}
}
}

Some files were not shown because too many files have changed in this diff Show More