init
This commit is contained in:
+36
@@ -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
|
||||
@@ -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
|
||||
@@ -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<QuestStatus>.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<QuestStatus>.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<PackedScene>
|
||||
/// 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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dj6iia51f8aj2
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dpvdu87dccvty
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://7nan7w2tiwqr
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bvdq2ib7ur3sp
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bwq0fljl2uowx
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bdok1ap2hkrbb
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dsxwk05liagaq
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://df7b2ywyuc7fx
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cimjjo0yddjtn
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://c20w72cfxcqsd
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://u4owqo1cjfay
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bl6y11rjgmexk
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bv26w63b0w31y
|
||||
@@ -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<QuestStatus>),
|
||||
/// 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<QuestStatus>.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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bjedcnwukcm7d
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://1he14yox77dj
|
||||
@@ -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<PackedScene>, 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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://b7k51qhnntc53
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bik36g8o0uk47
|
||||
@@ -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<QuestStatus>.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cr2l0a833awiu
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dn6emcv75xnch
|
||||
@@ -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 (0–1) 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 0–1 linear factor by
|
||||
/// dividing the raw 0–100 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 (0–1) 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://rn4u7ptpjlyw
|
||||
@@ -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 (0–1) 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://ckvpr338ndwqw
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://b5wifadjjdbe2
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://clpaindejc5fa
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://f6u7fic48uqg
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://buck210o3v7ga
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dd8fqikgkkpov
|
||||
@@ -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<string></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; }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dsl33e0umxjub
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://w766wlr8cbhk
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://crvtaessfkeh
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://qastbeowk183
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dd8as4eex76ea
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://mrtetivslsi5
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dp2sne06s0als
|
||||
@@ -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
Reference in New Issue
Block a user