88 Commits

Author SHA1 Message Date
4e6d5cf6f9 no idea what is it 2026-11-05 19:19:37 +01:00
e8d1da8f89 feat: lives and death move to the new domain (commit C of the swap)
fixes the regression commit B introduced: coins and skills lived in the
Attempt ledger, but nothing discarded it, because LivesStateHandler still
called the old store and HandlePlayerDeath was never invoked. Dying kept
everything earned that level. Discard-on-death is the ledger's purpose,
so it could not be separated from it.

new AttemptFailed(remaining, runOver) event, raised after the life is
spent and the ledger discarded. DeathScreen and GameOverScreen react to
that instead of being wired to HealthComponent.Death by scene connections
which fired *before* the life was charged - on the last life game-over
saw one life left and showed nothing, then the death screen saw zero and
refused to reload. 18 scene connections deleted; the race is now
unrepresentable rather than fixed.

DeathScreen also had two read paths for lives - a signal and a direct
store read - because neither was reliable alone. Now one.

Hud reads both facts from signals; only health still polls, and that
belongs to the component rather than to progression. GameManager no
longer touches GameStateStore or SaveSystem at all.
2026-07-28 20:19:35 +02:00
360f73ba24 feat: coins and skills move to the new domain (commit B of the swap)
atomic pair - purchasing couples them, so neither moves alone.

SkillData.Level is gone. It was an [Export] on a cached Resource: one
instance per process, so the level never persisted, leaked between save
slots, and made "unowned" indistinguishable from "level 1". Level now
comes from the Playthrough and is passed to ISkill.Initialize.

SkillManager reconciles from SkillSetChanged instead of being told by
each caller. The old shape was "record it, then tell SkillManager" as two
calls at four sites; AddSkill's early return when no player is registered
left the skill owned but inert. Reconciliation also tracks the level each
component was built at, so an upgrade rebuilds it rather than being
patched in from outside.

SkillUnlockerComponent collapses to one TryBuy - unlock and upgrade were
two methods each doing affordability, debit, level mutation and manager
notification in order.

The guard three components copy-pasted around Upgrades[Level - 1] - and
which BrickArmor omitted - now lives once in SkillComponentBase.

Hud reads coins from CoinsChanged instead of rebuilding translated
strings every frame. Lives still polls; it moves with the death path.
2026-07-28 20:13:24 +02:00
b6a8448743 feat(progression): capabilities the adapters need (commit A of the swap)
no behaviour change - additive only.

GrantSkill: pickups are free unlocks, and collecting one already owned is
a no-op rather than a free upgrade.
RevokeSkill: for skills that consume themselves (brick shield).
AbandonAttempt: leaving a level without dying, so no life is charged.

ApplyCheat rewrites the Playthrough through its snapshot rather than via
SetLives/AddCoins mutators, which ADR-0005 rules out - a bare mutator is
an invitation, and the old code had two paths to exactly that. Every
value is re-validated on the way back in and a rejected cheat leaves the
Playthrough untouched, which is asserted.

73 tests.
2026-07-28 20:07:47 +02:00
f4411e05a3 fix: SpeedRunHud never detached from the autoload's signal
every level load left another handler attached to a freed HUD.
SpeedRunManager outlives the scene, so it kept calling them and each one
wrote to a disposed Label - thousands of ObjectDisposedException per
frame, growing with each load.

Latent all along: StartTimer was only ever called from StartNewGame, so
after a Continue the timer never ticked and the dead handlers never ran.
Calling StartTimer on Continue (needed for splits and best times) made
it visible.

Also stop the timer when leaving to the menu - StopTimer existed and had
no callers, so the run kept ticking in the main menu.
2026-07-28 20:00:55 +02:00
a1c9042652 feat: level progression moves to the new domain (slice 10b, step 1)
narrowest safe step: only level identity and progression move. Coins,
lives, skills and death stay entirely on GameStateStore and behave as
before, so nothing is tracked in two places.

GameManager addresses levels by stable LevelId via the catalogue;
LevelScenes is gone, so reordering levels no longer invalidates saves.
Every level entry goes through EnterLevel, which fixes LevelStarted never
firing on the first level or after Continue - ghost recording and splits
have been dead outside mid-campaign.

GoTo announces after a frame, once the incoming scene is actually
current. ChangeSceneToPacked is deferred, so the old code handed
GhostEventHandler the scene being torn down.

FinishLevel takes LevelTime? - GetCurrentLevelTime can legitimately be 0
and inventing a value is exactly the bad data LevelTime refuses.

Continue distinguishes absent from corrupt and refuses to overwrite a
corrupt save. Statistics move to the Profile. 69 tests.
2026-07-28 19:57:01 +02:00
ca54bf0f82 feat(progression): read surface and change announcements (slice 10a)
reads for the UI: TotalCoins, Lives, IsRunOver, CurrentLevel,
LevelOf/IsOwned/CanAfford, HasCleared. The read half of candidate 01 -
callers get a narrow surface instead of reaching into state.

new domain events LivesChanged / TotalCoinsChanged / SkillSetChanged,
announced by comparing against the last announced value, so an operation
can neither forget to announce nor announce something unchanged.

HandlePlayerDeath now clears the spent attempt - it was still counting
towards TotalCoins after its ledger had been discarded.

republisher and EventBus extended; SkillSetChanged is what SkillManager
will reconcile against instead of the non-atomic call pairs. 65 tests.
2026-07-28 19:50:05 +02:00
c1c3e7b020 chore: editor artifacts - catalogue typed array, script uids, compiled translations 2026-07-28 19:46:30 +02:00
627900b2e8 feat(progression): composition root and event republisher (slice 9)
ProgressionRoot autoload builds the JSON stores from
GlobalizePath("user://"), loads the Profile, reads the authored upgrade
tables into a SkillCatalogue, and owns the ProgressionService. Every
mutating operation is wrapped so draining and republishing cannot be
forgotten - the one risk of the DrainEvents design, now confined to one
file.

ProgressionEventRepublisher turns domain records into EventBus signals
(ADR-0004), so existing subscribers and scene wiring keep working. New
RunEnded signal: GameOverScreen reads Lives directly today, which is the
last-life soft-lock.

Registered after SkillManager, since the skill catalogue is built from
AvailableSkills. GameStateStore stays until slice 10 moves the callers.

Transitional: LevelStarted is republished with a catalogue index because
GhostManager names files ghost_level_{index}.json.
2026-07-28 19:43:04 +02:00
f85c16c641 fix(levels): ScenePath must be a string, not a PackedScene
slice 7 regression. level_village_2..5.tscn each reference their own
LevelResource for the death screen label, so pointing the resource back
at the scene forms a cycle Godot cannot resolve - "Parse Error: Busy"
and the level fails to load entirely.

ScenePath was a string for exactly this reason. Reverted, and the rot it
allowed (forest_1 aimed at a nonexistent scene) is now caught by
LevelCatalogue.Validate checking every path resolves, plus a repo-level
structural check - fixed by validation rather than by typing.

project.godot: restores the C# feature flag and dialogue_manager
uses_dotnet, which `godot --headless --import` strips on every run.
2026-07-28 19:40:02 +02:00
7a69292066 feat(progression): json stores on a real filesystem (slice 8)
new progression.adapters project, no engine reference. Stores take a
directory path; Godot resolves user:// with GlobalizePath and passes it
in, which is what makes them testable at all - originally I'd written
this slice off as untestable.

version lives in a file envelope, not the snapshot, so the domain only
knows the current shape. A newer-build save reports Corrupt with a
reason instead of masquerading as "no save" and getting overwritten.

atomic write via temp + rename. One file per aggregate: saving a run
leaves the profile file byte-identical, asserted.

guard rail fixed - it was matching prose in doc comments, now anchored
to using-directives plus a project.assets.json check. 63 tests.
2026-07-28 19:35:43 +02:00
f991bb99fc feat(levels): level catalogue with stable ids (slice 7)
LevelResource gains a stable Id and swaps the string ScenePath for an
editor-tracked PackedScene. The old path field was never read and had
rotted - forest_1 pointed at level_forest_5.tscn, which doesn't exist,
and borrowed LEVEL_5_NAME so it would have displayed "VILLAGE - 5".

LevelCatalogue lists levels in campaign order and produces the domain's
Campaign; an authoring mistake fails at boot because Campaign.Of rejects
blank and duplicate ids. Not a domain port - nothing in the domain
resolves a level to a scene.

All 8 scenes now have a resource; campaign keeps the existing 6
(tutorial_1, village_1..5). village_6 and forest_1 are catalogued but
deliberately out of the campaign.

GameManager.LevelScenes stays until slice 10 rewrites GameManager.
2026-07-28 19:28:56 +02:00
330e10880c feat(progression): use cases (slice 6)
ProgressionService holds the loaded Profile, active Playthrough and
active Attempt, so "exactly one active Attempt" has a testable home.

EnterLevel always announces LevelStarted - the reason ghost recording
and splits never worked on level 1 or after a Continue. Refuses
unreachable levels. Does not touch campaign position, so a Replay
leaves it alone.

SkillCatalogue returns null for a skill with no authored upgrades,
killing the Upgrades[0].Cost overrun. PurchaseSkill reads the current
level from the ledger first, so one Attempt can unlock then upgrade.
HandlePlayerDeath now persists - death was free if you quit.

SkillLevel.First/Next driven out here by a real caller. 53 tests.
2026-07-28 19:10:06 +02:00
003ec4b010 feat(progression): snapshots, ports, in-memory adapters (slice 5)
flat snapshot record per aggregate - separate files mean saving a run
doesn't rewrite the Profile, deleting a slot is deleting a file, and a
corrupt slot doesn't cost achievements or best times.

snapshots hold primitives so Restore re-runs the value-object factories;
a serialiser building LevelId directly would bypass every guard.

LoadResult has three outcomes: Absent != Corrupt. Collapsing them is the
bug where a version bump made ContinueGame overwrite the save.

Profile.Restore writes collections directly - replaying UnlockAchievement
would re-fire popups and Steam pushes on every load. 44 tests.
2026-07-28 19:02:41 +02:00
05149bb4a9 feat(progression): Profile aggregate (slice 4)
best times only improve; achievement unlock announces on the transition
only, so popup/Steam subscribers cannot double-fire; statistics are
increase-only (IncrementStat took any int).

RelockAchievement is console-only and documented as not reaching Steam -
a Steam achievement cannot be un-earned, so the disagreement is
permanent and deliberate.

refactor: extract EventLog, composed by both aggregates now that the
duplication actually exists. 34 tests.
2026-07-28 18:56:58 +02:00
ffa2b986ae feat(progression): Playthrough aggregate (slice 3)
no LoseLife (ADR-0005). SettleDeath / SettleCompletion defer to the
Attempt's terminal outcome, so both are atomic and idempotent.
SettleCompletion branches on HasCleared itself - a Replay cannot bank
or move campaign position backwards (ADR-0003).

Campaign value object holds authored level order; ReachableLevels is
derived, replacing the stored UnlockedLevels. Coins.IsAtLeast driven
out by cross-scope affordability.

29 tests. CONTEXT.md: Campaign, Settle.
2026-07-28 18:52:20 +02:00
40bd5c1d86 feat(progression): Attempt ledger with terminal-outcome guard (slice 2)
Attempt carries its LevelId so settlement decides First Clear vs Replay
without trusting the caller. Outcome is terminal and refuses to change:
one death costs one life, one completion banks once.

Coins.Zero/Add driven out by the ledger rather than added up front.
21 tests. ADR-0005: Playthrough settles Attempts, has no LoseLife.
2026-07-28 18:40:20 +02:00
9767941c65 feat(progression): remaining slice 1 value objects
Lives (floors at zero, exhaustion observable), LevelId, SkillId,
PlaythroughId (never self-generates, keeps tests deterministic),
StatName enum, LevelTime rejects NaN/infinity.

14 tests. Arithmetic/comparison members deferred to the slices whose
behaviour needs them rather than added speculatively.
2026-07-28 18:27:26 +02:00
313a5315b1 feat(progression): Coins, LevelTime, SkillLevel core invariants (slice 1)
three bug-encoding invariants, one red/green cycle each:
- Coins.TrySubtract fails instead of clamping
- LevelTime rejects zero (continued-run zero best-times)
- SkillLevel has no zero (unowned is absence, not Level=1)

plan: drop signed NetCoins; add Attempt outcome as idempotency guard
2026-07-28 18:22:46 +02:00
c3bbf3282a chore: scaffold progression domain + test projects (slice 0)
net8.0 classlib, no engine ref, EnableDynamicLoading; xunit test project
referencing domain only; excluded from Godot's **/*.cs glob; .gdignore both
2026-07-28 18:14:24 +02:00
dd113af786 docs: progression CONTEXT, ADRs 0001-0004, phase A plan
ignore .idea/ .superpowers/ bin/ obj/; drop 3 identical csproj backups
2026-07-28 18:11:01 +02:00
6b784303b4 fix: update author name in plugin configuration 2026-04-26 05:09:18 +02:00
562be1049d fix: replace deprecated AddControlToDock with AddDock(EditorDock) 2026-04-26 05:07:13 +02:00
b2a406ca2b add cellular automata generator 2026-04-26 05:02:03 +02:00
7f0869ffa4 fix: update unique IDs and texture path in collapsing_block scene 2026-04-26 03:36:23 +02:00
7f1b4b5e0e levels update 2026-04-26 03:15:44 +02:00
8769308c85 feat: add 'down' action to remappable actions and update unique IDs in input settings 2026-04-26 02:43:50 +02:00
4b63368286 fix: update Godot.NET.Sdk version to 4.6.2 2026-04-24 11:44:03 +02:00
cbf625d2ff fix: update scene files for unique IDs and adjust JumpForce in JumpPadComponent 2026-03-19 11:32:48 +01:00
057cf7b368 feat: add UID files for InputDeviceManager, SettingsManager, DisplaySettings, GameplaySettings, and InputSettings 2026-03-19 03:53:49 +01:00
829d081eab fix: unsubscribe VisibilityChanged in _ExitTree, use IsActionReleased for ui_cancel, expose ApplyGamepadDeadzone public 2026-03-19 03:51:01 +01:00
38c86f1a67 feat: gameplay settings screen (deadzone + sensitivity) 2026-03-19 03:47:15 +01:00
e647cd7b29 feat: marketplace keyboard/gamepad navigation 2026-03-19 03:44:35 +01:00
9043cfa63c fix: InputSettings PhysicalKeycode, visibility guard, dead code, delegate save 2026-03-19 03:42:47 +01:00
dc46769c11 feat: input remapping screen with conflict detection and config persistence 2026-03-19 03:39:19 +01:00
47600be867 fix: route resolution change through SettingsManager.ApplyDisplaySettings 2026-03-19 03:36:29 +01:00
3e74959bb9 fix: DisplaySettings focus mode, remove duplicate ApplyWindowMode 2026-03-19 03:35:28 +01:00
8cce1866b1 feat: display settings screen with window mode and resolution picker 2026-03-19 03:32:40 +01:00
0105698c33 fix: guard GrabFirstFocus on pointerless, unsub VisibilityChanged in _ExitTree 2026-03-19 03:29:36 +01:00
3b18e328b2 feat: gamepad/keyboard UI navigation on all menus 2026-03-19 03:26:50 +01:00
2bc0b76050 fix: joypad drift threshold + move InputDevice enum inside class 2026-03-19 03:24:19 +01:00
d83e3b4d82 feat: add InputDeviceManager autoload for input device tracking 2026-03-19 03:22:17 +01:00
42d7df27ae fix: SettingsManager null guard, ui_ filter, double cast for sensitivity/deadzone 2026-03-19 03:21:04 +01:00
fd54e16bf4 fix: make GamepadDeadzone/Sensitivity/WindowMode/Resolution setters public 2026-03-19 03:18:46 +01:00
92a3c4f79a feat: add SettingsManager autoload for display, gamepad and input binding settings 2026-03-19 03:17:26 +01:00
654a8b7ea7 fix: add .worktrees/ to gitignore 2026-03-19 03:15:09 +01:00
53ebbbbac3 feat: add movement presets for platform and spaceship, update abilities in scenes 2026-03-19 02:55:03 +01:00
551dc09485 feat: add unique identifier for MovementPreset 2026-03-19 02:46:55 +01:00
814d9736d0 refactor: movement system - MovementPreset, decouple abilities, fix timing 2026-03-19 02:45:26 +01:00
adaeb35fdd chore: ignore .worktrees dir 2026-03-19 02:43:41 +01:00
eeefca4d4e refactor: enhance GameStateStore integration and improve skill management 2026-03-19 02:33:07 +01:00
3e36e48e97 refactor: update collision shape disabling method for improved performance 2026-03-19 02:11:28 +01:00
1e9814a9d8 refactor: replace direct manager instances with lazy accessors for improved performance 2026-03-19 02:10:36 +01:00
470b0c3a8c refactor: implement singleton pattern for key managers and improve resource access 2026-03-19 02:00:56 +01:00
cfef49fbc7 refactor: standardization round 2
- ConsoleManager: lazy GameManager/AchievementManager via Instance (fixes NullRef on console commands)
- AchievementManager, GhostManager: add static Instance property
- GhostEventHandler: use GhostManager.Instance, add _ExitTree unsubscription
- SpeedRunManager: remove unused IsVisible guard; TimeUpdated now emits when running
- SpeedRunHud: use SpeedRunManager.Instance, remove dead IsVisible binding
- SaveDataDto: moved to scripts/State/SaveDataDto.cs
- GameManager.AddCoins: XML doc clarifying permanent vs session coins
2026-03-19 01:50:20 +01:00
321905e68e refactor: fix bugs and improve architecture
- Fix double-execution bug in LevelStateHandler (coins/skills were committed twice per level)
- Fix DamageComponent to track multiple targets via HashSet instead of single node
- Fix HealthComponent: update health immediately, decouple from PlayerController via signal wiring in PlayerController
- Remove dead loop in SkillManager.RemoveSkill; simplify O(n²) throw skill loop
- Replace GetNode<T>(path) with .Instance across managers; add Instance to StatisticsManager/SpeedRunManager
- GameManager.GetPlayer() now uses EventBus.PlayerSpawned instead of scanning all scene nodes
- UIManager.UiStack: remove [Export], use private List<Control>
- PlayerState: extract DefaultLives constant, simplify CreateDefault()
2026-03-19 01:41:14 +01:00
427bef6509 Add EventBus to project settings 2026-02-02 12:48:17 +01:00
3616f4ba79 Update collectable resources and improve FloatingTextManager integration 2026-02-02 12:45:48 +01:00
2bf903728b Add CollectableType.None and update project settings for Godot 4.6 2026-02-01 13:11:14 +01:00
bfe951939d refactor (#6)
Reviewed-on: #6
2026-02-01 11:47:40 +00:00
dde3eaa52e godot-4.5 (#5)
Reviewed-on: #5
2026-01-31 13:06:43 +00:00
bc3108ea37 Add MovingPlatformComponent and moving platform scene; implement movement along a path with customizable speed and loop modes 2025-09-13 14:43:58 +02:00
7257242fce Add cannon_bullet scene and update cannon settings; implement new bullet behavior and shooting interval 2025-09-13 14:22:12 +02:00
03abf91f59 Add RecoilComponent and SquashAndStretchComponent; implement recoil and animation effects on shooting 2025-09-13 14:18:43 +02:00
e6f8989d16 Add EnemyControllerComponent and PeriodicShootingComponent; implement enemy shooting behavior and bullet spawning 2025-09-13 13:53:40 +02:00
db2a090acc Add FootstepGfx and JumpGfxComponent; implement particle effects for footsteps and jumps 2025-09-13 05:19:37 +02:00
dfc9201f62 Add EventBus, SpeedRunManager, and GhostManager; implement ghost recording and playback features 2025-09-13 03:30:15 +02:00
46553a351a Add achievement panel texture and update AchievementPopup design 2025-09-13 02:26:40 +02:00
a8ff492aed Pacxon (#12)
* Add GridMovementAbility and PacXonGridInteractor for grid-based movement; integrate with PlayerController and PacXonLevel

* Add GhostMovementComponent and PacXonTrailComponent; integrate ghost movement and trail features in PacXonLevel

* Update main menu button focus and add new movement abilities; adjust player and ghost initialization in PacXonLevel
2025-09-13 01:52:07 +02:00
aa73e54b3e Add KnockbackComponent and HazardComponent for enhanced enemy interactions; integrate knockback effects in DamageComponent 2025-09-11 05:09:24 +02:00
98b3202361 Update FloatingTextManager colors and integrate floating text display in LeverComponent; replace enemy sprite in basic_enemy scene 2025-09-11 04:42:59 +02:00
6e2bdcdf95 Add FloatingText and FloatingTextManager for UI message display; create basic_enemy scene and integrate components 2025-09-11 04:35:39 +02:00
f229ff5b7d Add FloatingTextManager and FloatingText for displaying UI messages; update CollectableComponent and HealthComponent to utilize new floating text features 2025-09-11 04:35:30 +02:00
f9cb59d182 Add HealthComponent to bullet and update various resources for skill upgrades 2025-08-31 15:17:47 +02:00
ead52f6d51 Add new skills: Double Jump, Ground Pound, Brick Armor, and Brick Shield; implement skill removal signal in SkillManager (#11) 2025-08-31 15:17:11 +02:00
bd40c797d4 Add SkillManager class and integrate with PlayerController and SkillUnlockerComponent (#10) 2025-08-31 13:04:50 +02:00
2d520a708f Add SkillManager node to village levels and update references 2025-08-31 03:01:24 +02:00
257a492f72 clean up 2025-08-31 02:57:22 +02:00
fd10e566b3 Add ProgressiveDamageComponent for dynamic damage feedback 2025-08-31 02:56:33 +02:00
021e984877 Add DamageNumberManager and DamageNumber for displaying damage feedback 2025-08-31 02:30:42 +02:00
4b7c38397c Remove ProgressiveDamageComponent from village level 2025-08-31 02:13:45 +02:00
6d0e337bd6 Add ProgressiveDamageComponent to village levels and update .gitattributes 2025-08-31 02:12:56 +02:00
51aecf7da5 Refactor FlipComponent to use PlayerController and remove ProgressiveDamageComponent from scene 2025-08-31 02:11:05 +02:00
604520cad5 Wave enemies (#9)
* Add enemy wave management and path following components

* cleanup
2025-08-31 02:08:35 +02:00
36d1cac284 Add PlayerGraphicsComponent to manage player sprite visibility based on movement abilities 2025-08-31 01:14:00 +02:00
53b5f968fd Add achievement system with unlock and UI popup functionality (#8) 2025-08-31 00:50:53 +02:00
88c7a0a055 Movement refactor 2025-08-30 23:06:12 +02:00
d786ef4c22 Complete C# rewrite with working game in Editor (#6)
* Refactor collectable components to C# and update resource scripts for consistency

* Update resource paths and refactor properties for consistency

* Refactor UI components to inherit from Control and update node paths for consistency

* Update node paths and group assignments for consistency across scenes

* Refactor GameManager and PlayerDeathComponent for improved state management and logging; update scene connections for player death handling

* Add PhantomCamera components and UI elements for improved scene management; refactor existing components for better integration

* Refactor skill components and update resource paths for consistency; enhance skill management in scenes

* Add new UID files and update scene configurations for dialogue components; refactor skill management and input handling

* Add next level command and refactor player retrieval in GameManager; update scene files for consistency

* Add skill upgrade system and refactor skill components for enhanced functionality; update resource paths and configurations

* Enhance ChargeProgressBar and Marketplace functionality; add owner exit handling and update skill button states

* Refactor ChargeProgressBar and SkillManager; update skill handling and improve component interactions

* Refactor player and level configurations; streamline FlipPlayerComponent and reposition Spaceship Enter
2025-08-27 01:12:26 +02:00
1002 changed files with 40462 additions and 9222 deletions

2
.gitattributes vendored
View File

@@ -1,2 +1,4 @@
# Normalize EOL for all files that Git considers text files. # Normalize EOL for all files that Git considers text files.
* text=auto eol=lf * text=auto eol=lf
addons/* linguist-vendored
*.gd linguist-vendored

11
.gitignore vendored
View File

@@ -1,4 +1,13 @@
# Godot 4+ specific ignores # Godot 4+ specific ignores
.godot/ .godot/
/android/ /android/
builds/ builds/
.worktrees/
# IDE / local tooling
.idea/
.superpowers/
# .NET build output
bin/
obj/

View File

@@ -0,0 +1,110 @@
using Godot;
using Mr.BrickAdventures.scripts.Resources;
using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Manages achievements using GameStateStore.
/// </summary>
public partial class AchievementManager : Node
{
public static AchievementManager Instance { get; private set; }
[Export] private string AchievementsFolderPath = "res://achievements/";
[Export] private PackedScene AchievementPopupScene { get; set; }
private System.Collections.Generic.Dictionary<string, AchievementResource> _achievements = new();
public override void _Ready()
{
Instance = this;
LoadAchievementsFromFolder();
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
}
private void LoadAchievementsFromFolder()
{
using var dir = DirAccess.Open(AchievementsFolderPath);
if (dir == null)
{
GD.PrintErr($"AchievementManager: Could not open achievements folder at '{AchievementsFolderPath}'");
return;
}
dir.ListDirBegin();
var fileName = dir.GetNext();
while (fileName != "")
{
if (!dir.CurrentIsDir() && fileName.EndsWith(".tres"))
{
var achievement = GD.Load<AchievementResource>(AchievementsFolderPath + fileName);
if (achievement != null)
{
_achievements.TryAdd(achievement.Id, achievement);
}
}
fileName = dir.GetNext();
}
}
/// <summary>
/// Gets the list of unlocked achievement IDs from the store.
/// </summary>
private System.Collections.Generic.List<string> GetUnlockedIds()
{
return GameStateStore.Instance?.Player.UnlockedAchievements ?? new System.Collections.Generic.List<string>();
}
public void UnlockAchievement(string achievementId)
{
if (!_achievements.TryGetValue(achievementId, out var achievement))
{
GD.PrintErr($"Attempted to unlock non-existent achievement: '{achievementId}'");
return;
}
var unlockedIds = GetUnlockedIds();
if (unlockedIds.Contains(achievementId))
{
return; // Already unlocked
}
// 1. Mark as unlocked
unlockedIds.Add(achievementId);
GD.Print($"Achievement Unlocked: {achievement.DisplayName}");
EventBus.EmitAchievementUnlocked(achievementId);
// 2. Show the UI popup
if (AchievementPopupScene != null)
{
var popup = AchievementPopupScene.Instantiate<scripts.UI.AchievementPopup>();
GetTree().Root.AddChild(popup);
_ = popup.ShowAchievement(achievement);
}
// 3. Call SteamManager if it's available
if (SteamManager.IsSteamInitialized)
{
SteamManager.UnlockAchievement(achievement.Id);
}
}
public void LockAchievement(string achievementId)
{
var unlockedIds = GetUnlockedIds();
if (unlockedIds.Contains(achievementId))
{
unlockedIds.Remove(achievementId);
}
}
public bool IsAchievementUnlocked(string achievementId)
{
return GetUnlockedIds().Contains(achievementId);
}
}

View File

@@ -0,0 +1 @@
uid://c4vvuqnx5y33u

View File

@@ -4,13 +4,16 @@ namespace Mr.BrickAdventures.Autoloads;
public partial class ConfigFileHandler : Node public partial class ConfigFileHandler : Node
{ {
public static ConfigFileHandler Instance { get; private set; }
private ConfigFile _settingsConfig = new(); private ConfigFile _settingsConfig = new();
public const string SettingsPath = "user://settings.ini"; public const string SettingsPath = "user://settings.ini";
public ConfigFile SettingsConfig => _settingsConfig; public ConfigFile SettingsConfig => _settingsConfig;
public override void _Ready() public override void _Ready()
{ {
Instance = this;
if (!FileAccess.FileExists(SettingsPath)) if (!FileAccess.FileExists(SettingsPath))
{ {
var err = _settingsConfig.Save(SettingsPath); var err = _settingsConfig.Save(SettingsPath);
@@ -24,4 +27,9 @@ public partial class ConfigFileHandler : Node
GD.PushError($"Failed to load settings file at {SettingsPath}: {err}"); GD.PushError($"Failed to load settings file at {SettingsPath}: {err}");
} }
} }
public override void _ExitTree()
{
if (Instance == this) Instance = null;
}
} }

120
Autoloads/ConsoleManager.cs Normal file
View File

@@ -0,0 +1,120 @@
using Godot;
using Mr.BrickAdventures.scripts.components;
namespace Mr.BrickAdventures.Autoloads;
public partial class ConsoleManager : Node
{
private GameStateStore Store => GameStateStore.Instance;
private GameManager GameManager => GameManager.Instance;
private AchievementManager AchievementManager => AchievementManager.Instance;
private SkillManager _skillManager;
private SkillUnlockerComponent _skillUnlockerComponent;
public override void _Ready()
{
_skillManager = SkillManager.Instance;
}
private void AddCoinsCommand(int amount)
{
if (Store == null) return;
Store.Player.Coins += amount;
EventBus.EmitCoinsChanged(Store.GetTotalCoins());
}
private void SetCoinsCommand(int amount)
{
if (Store == null) return;
Store.Player.Coins = Mathf.Max(0, amount);
EventBus.EmitCoinsChanged(Store.GetTotalCoins());
}
private void SetLivesCommand(int amount)
{
if (Store == null) return;
Store.Player.Lives = amount;
EventBus.EmitLivesChanged(amount);
}
private void AddLivesCommand(int amount)
{
Store?.AddLives(amount);
}
private void SetHealthCommand(float amount)
{
var playerHealthComponent = GameManager?.Player?.GetNode<HealthComponent>("HealthComponent");
if (playerHealthComponent != null)
playerHealthComponent.Health = amount;
}
private void ResetSessionCommand()
{
Store?.ResetSession();
}
private void UnlockSkillCommand(string skillName)
{
if (!EnsureSkillManagement()) return;
var skill = _skillManager.GetSkillByName(skillName);
if (skill == null) return;
Store?.UnlockSkillPermanently(skill);
_skillManager.ActivateSkill(skill);
_skillUnlockerComponent.EmitSignal(SkillUnlockerComponent.SignalName.SkillUnlocked, skill);
}
private void UnlockAllSkillsCommand()
{
if (!EnsureSkillManagement()) return;
_skillUnlockerComponent.UnlockAllSkills();
}
private void RemoveSkillCommand(string skillName)
{
if (!EnsureSkillManagement()) return;
var skill = _skillManager.GetSkillByName(skillName);
if (skill == null) return;
Store?.RemoveUnlockedSkill(skill.Name);
_skillManager.DeactivateSkill(skill);
}
private void RemoveAllSkillsCommand()
{
if (!EnsureSkillManagement()) return;
foreach (var skill in _skillManager.AvailableSkills)
{
Store?.RemoveUnlockedSkill(skill.Name);
_skillManager.DeactivateSkill(skill);
}
}
private void GoToNextLevelCommand()
{
GameManager?.OnLevelComplete();
}
private void UnlockAchievementCommand(string achievementId)
{
AchievementManager?.UnlockAchievement(achievementId);
}
private void ResetAchievementCommand(string achievementId)
{
AchievementManager?.LockAchievement(achievementId);
}
private bool EnsureSkillManagement()
{
var player = GameManager?.Player;
if (player == null || !IsInstanceValid(player)) return false;
_skillUnlockerComponent ??= player.GetNode<SkillUnlockerComponent>("SkillUnlockerComponent");
return _skillManager != null && _skillUnlockerComponent != null;
}
}

View File

@@ -0,0 +1 @@
uid://bv2teruv4s6vg

194
Autoloads/EventBus.cs Normal file
View File

@@ -0,0 +1,194 @@
using Godot;
using Mr.BrickAdventures.scripts.components;
using Mr.BrickAdventures.scripts.Resources;
namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Global event bus for decoupled communication between game systems.
/// Use the static Instance property for easy access from anywhere.
/// </summary>
public partial class EventBus : Node
{
/// <summary>
/// Singleton instance. Available after the autoload is initialized.
/// </summary>
public static EventBus Instance { get; private set; }
public override void _Ready()
{
Instance = this;
}
public override void _ExitTree()
{
if (Instance == this)
Instance = null;
}
#region Level Events
[Signal] public delegate void LevelStartedEventHandler(int levelIndex, Node currentScene);
[Signal] public delegate void LevelCompletedEventHandler(int levelIndex, Node currentScene, double completionTime);
[Signal] public delegate void LevelRestartedEventHandler(int levelIndex);
public static void EmitLevelStarted(int levelIndex, Node currentScene)
=> Instance?.EmitSignal(SignalName.LevelStarted, levelIndex, currentScene);
public static void EmitLevelCompleted(int levelIndex, Node currentScene, double completionTime)
=> Instance?.EmitSignal(SignalName.LevelCompleted, levelIndex, currentScene, completionTime);
public static void EmitLevelRestarted(int levelIndex)
=> Instance?.EmitSignal(SignalName.LevelRestarted, levelIndex);
#endregion
#region Player Events
[Signal] public delegate void PlayerSpawnedEventHandler(PlayerController player);
[Signal] public delegate void PlayerDiedEventHandler(Vector2 position);
[Signal] public delegate void PlayerDamagedEventHandler(float damage, float remainingHealth, Vector2 position);
[Signal] public delegate void PlayerHealedEventHandler(float amount, float newHealth, Vector2 position);
public static void EmitPlayerSpawned(PlayerController player)
=> Instance?.EmitSignal(SignalName.PlayerSpawned, player);
public static void EmitPlayerDied(Vector2 position)
=> Instance?.EmitSignal(SignalName.PlayerDied, position);
public static void EmitPlayerDamaged(float damage, float remainingHealth, Vector2 position)
=> Instance?.EmitSignal(SignalName.PlayerDamaged, damage, remainingHealth, position);
public static void EmitPlayerHealed(float amount, float newHealth, Vector2 position)
=> Instance?.EmitSignal(SignalName.PlayerHealed, amount, newHealth, position);
#endregion
#region Combat Events
[Signal] public delegate void EnemyDefeatedEventHandler(Node enemy, Vector2 position);
[Signal] public delegate void EnemyDamagedEventHandler(Node enemy, float damage, Vector2 position);
public static void EmitEnemyDefeated(Node enemy, Vector2 position)
=> Instance?.EmitSignal(SignalName.EnemyDefeated, enemy, position);
public static void EmitEnemyDamaged(Node enemy, float damage, Vector2 position)
=> Instance?.EmitSignal(SignalName.EnemyDamaged, enemy, damage, position);
#endregion
#region Collection Events
[Signal] public delegate void CoinCollectedEventHandler(int amount, Vector2 position);
[Signal] public delegate void ItemCollectedEventHandler(CollectableType itemType, float amount, Vector2 position);
[Signal] public delegate void ChildRescuedEventHandler(Vector2 position);
[Signal] public delegate void SkillCollectedEventHandler(SkillData skill, Vector2 position);
public static void EmitCoinCollected(int amount, Vector2 position)
=> Instance?.EmitSignal(SignalName.CoinCollected, amount, position);
public static void EmitItemCollected(CollectableType itemType, float amount, Vector2 position)
=> Instance?.EmitSignal(SignalName.ItemCollected, (int)itemType, amount, position);
public static void EmitChildRescued(Vector2 position)
=> Instance?.EmitSignal(SignalName.ChildRescued, position);
public static void EmitSkillCollected(SkillData skill, Vector2 position)
=> Instance?.EmitSignal(SignalName.SkillCollected, skill, position);
#endregion
#region Skill Events
[Signal] public delegate void SkillUnlockedEventHandler(string skillName, int level);
[Signal] public delegate void SkillActivatedEventHandler(string skillName);
[Signal] public delegate void SkillDeactivatedEventHandler(string skillName);
public static void EmitSkillUnlocked(string skillName, int level = 1)
=> Instance?.EmitSignal(SignalName.SkillUnlocked, skillName, level);
public static void EmitSkillActivated(string skillName)
=> Instance?.EmitSignal(SignalName.SkillActivated, skillName);
public static void EmitSkillDeactivated(string skillName)
=> Instance?.EmitSignal(SignalName.SkillDeactivated, skillName);
#endregion
#region Game State Events
[Signal] public delegate void GamePausedEventHandler();
[Signal] public delegate void GameResumedEventHandler();
[Signal] public delegate void GameSavedEventHandler();
[Signal] public delegate void GameStartedEventHandler();
[Signal] public delegate void GameContinuedEventHandler();
public static void EmitGamePaused()
=> Instance?.EmitSignal(SignalName.GamePaused);
public static void EmitGameResumed()
=> Instance?.EmitSignal(SignalName.GameResumed);
public static void EmitGameSaved()
=> Instance?.EmitSignal(SignalName.GameSaved);
public static void EmitGameStarted()
=> Instance?.EmitSignal(SignalName.GameStarted);
public static void EmitGameContinued()
=> Instance?.EmitSignal(SignalName.GameContinued);
#endregion
#region Achievement Events
[Signal] public delegate void AchievementUnlockedEventHandler(string achievementId);
public static void EmitAchievementUnlocked(string achievementId)
=> Instance?.EmitSignal(SignalName.AchievementUnlocked, achievementId);
#endregion
#region State Change Events
[Signal] public delegate void CoinsChangedEventHandler(int totalCoins);
[Signal] public delegate void LivesChangedEventHandler(int lives);
/// <summary>
/// The Playthrough has no lives left. Raised as part of the same operation that spends the last
/// life, so a subscriber can never observe lives that have not been decremented yet — which is
/// the race that lets the death screen sit there forever on the last life.
/// </summary>
[Signal] public delegate void RunEndedEventHandler();
/// <summary>
/// A death has been settled. The life is already spent and the attempt already discarded, so
/// <paramref name="remainingLives"/> is safe to display directly — unlike reading Lives from a
/// handler racing the one that writes it.
/// </summary>
[Signal] public delegate void AttemptFailedEventHandler(int remainingLives, bool runOver);
public static void EmitAttemptFailed(int remainingLives, bool runOver)
=> Instance?.EmitSignal(SignalName.AttemptFailed, remainingLives, runOver);
public static void EmitRunEnded()
=> Instance?.EmitSignal(SignalName.RunEnded);
/// <summary>
/// The set of owned skills, or the level of one of them, changed. SkillManager reconciles its live
/// components against this instead of every caller remembering to update it separately — the
/// non-atomic pair of calls that could leave a skill owned but inert.
/// </summary>
[Signal] public delegate void SkillSetChangedEventHandler();
public static void EmitSkillSetChanged()
=> Instance?.EmitSignal(SignalName.SkillSetChanged);
public static void EmitCoinsChanged(int totalCoins)
=> Instance?.EmitSignal(SignalName.CoinsChanged, totalCoins);
public static void EmitLivesChanged(int lives)
=> Instance?.EmitSignal(SignalName.LivesChanged, lives);
#endregion
}

View File

@@ -0,0 +1 @@
uid://bb2yq5wggdw3w

View File

@@ -0,0 +1,66 @@
using System;
using System.Globalization;
using Godot;
using Mr.BrickAdventures.scripts.Resources;
using Mr.BrickAdventures.scripts.UI;
namespace Mr.BrickAdventures.Autoloads;
public partial class FloatingTextManager : Node
{
[Export] public PackedScene FloatingTextScene { get; set; }
[ExportGroup("Colors")]
[Export] public Color DamageColor { get; set; } = new Color("#b21030"); // Red
[Export] public Color HealColor { get; set; } = new Color("#71f341"); // Green
[Export] public Color CoinColor { get; set; } = new Color("#ebd320"); // Gold
[Export] public Color MessageColor { get; set; } = new Color("#ffffff"); // White
public static FloatingTextManager Instance { get; private set; }
public override void _Ready()
{
Instance = this;
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
}
public void ShowDamage(float amount, Vector2 position)
{
var text = Mathf.Round(amount * 100f).ToString(CultureInfo.InvariantCulture);
CreateFloatingText(text, position, DamageColor);
}
public void ShowHeal(float amount, Vector2 position)
{
var text = $"+{Mathf.Round(amount * 100f).ToString(CultureInfo.InvariantCulture)}";
CreateFloatingText(text, position, HealColor);
}
public void ShowCoin(int amount, Vector2 position)
{
var text = $"+{amount}";
CreateFloatingText(text, position, CoinColor);
}
public void ShowMessage(string message, Vector2 position)
{
CreateFloatingText(message, position, MessageColor);
}
private void CreateFloatingText(string text, Vector2 position, Color color)
{
if (FloatingTextScene == null)
{
GD.PushError("FloatingTextManager: FloatingTextScene is not set!");
return;
}
var popup = FloatingTextScene.Instantiate<FloatingText>();
GetTree().CurrentScene.AddChild(popup);
popup.Show(text, position, color);
}
}

View File

@@ -0,0 +1 @@
uid://cobgfsr3gw7cn

View File

@@ -1,241 +1,191 @@
using System.Collections.Generic;
using System.Linq;
using Godot; using Godot;
using Godot.Collections; using Mr.BrickAdventures.Progression;
using Mr.BrickAdventures.scripts.components; using Mr.BrickAdventures.scripts.components;
using Mr.BrickAdventures.scripts.Resources;
namespace Mr.BrickAdventures.Autoloads; namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Game orchestrator: scene transitions and game flow.
/// </summary>
/// <remarks>
/// <para>
/// Which Level the player is on now belongs to <see cref="ProgressionRoot"/>, addressed by a stable
/// <see cref="LevelId"/> and resolved to a scene by the level catalogue. The old
/// <c>Array&lt;PackedScene&gt; LevelScenes</c> is gone: it made a save's meaning depend on the order
/// of an inspector array, so reordering levels silently invalidated every save.
/// </para>
/// <para>
/// Progression is persisted by <see cref="ProgressionRoot"/>'s stores, which save themselves at the
/// points that matter — a First Clear and a death — so there is no separate "save the game" step to
/// forget.
/// </para>
/// </remarks>
public partial class GameManager : Node public partial class GameManager : Node
{ {
[Export] public Array<PackedScene> LevelScenes { get; set; } = new(); public PlayerController Player
public PlayerController Player { get; set; }
private List<Node> _sceneNodes = new();
public Dictionary PlayerState { get; set; } = new()
{ {
{ "coins", 0 }, get => GetPlayer();
{ "lives", 3 }, private set => _player = value;
{ "current_level", 0 }, }
{ "completed_levels", new Array<int>() },
{ "unlocked_levels", new Array<int>() {0}},
{ "unlocked_skills", new Array<SkillData>() }
};
public Dictionary CurrentSessionState { get; private set; } = new() private PlayerController _player;
{ private SpeedRunManager SpeedRunManager => SpeedRunManager.Instance;
{ "coins_collected", 0 }, private ProgressionRoot Progression => ProgressionRoot.Instance;
{ "skills_unlocked", new Array<SkillData>() }
};
public override void _EnterTree() public static GameManager Instance { get; private set; }
public override void _Ready()
{ {
GetTree().NodeAdded += OnNodeAdded; Instance = this;
GetTree().NodeRemoved += OnNodeRemoved; EventBus.Instance.PlayerSpawned += OnPlayerSpawned;
} }
public override void _ExitTree() public override void _ExitTree()
{ {
GetTree().NodeAdded -= OnNodeAdded; if (Instance == this) Instance = null;
GetTree().NodeRemoved -= OnNodeRemoved; if (EventBus.Instance != null)
_sceneNodes.Clear(); EventBus.Instance.PlayerSpawned -= OnPlayerSpawned;
} }
private void OnNodeAdded(Node node) private void OnPlayerSpawned(PlayerController player)
{ {
_sceneNodes.Add(node); _player = player;
} player.TreeExiting += () => { if (_player == player) _player = null; };
private void OnNodeRemoved(Node node)
{
_sceneNodes.Remove(node);
} }
public void AddCoins(int amount) #region Game Flow
{
PlayerState["coins"] = Mathf.Max(0, (int)PlayerState["coins"] + amount);
}
public void SetCoins(int amount) => PlayerState["coins"] = Mathf.Max(0, amount);
public int GetCoins() => (int)PlayerState["coins"] + (int)CurrentSessionState["coins_collected"];
public void RemoveCoins(int amount)
{
var sessionCoins = (int)CurrentSessionState["coins_collected"];
if (amount <= sessionCoins)
{
CurrentSessionState["coins_collected"] = sessionCoins - amount;
}
else
{
var remaining = amount - sessionCoins;
CurrentSessionState["coins_collected"] = 0;
PlayerState["coins"] = Mathf.Max(0, (int)PlayerState["coins"] - remaining);
}
PlayerState["coins"] = Mathf.Max(0, (int)PlayerState["coins"]);
}
public void AddLives(int amount) => PlayerState["lives"] = (int)PlayerState["lives"] + amount;
public void RemoveLives(int amount) => PlayerState["lives"] = (int)PlayerState["lives"] - amount;
public void SetLives(int amount) => PlayerState["lives"] = amount;
public int GetLives() => (int)PlayerState["lives"];
public bool IsSkillUnlocked(SkillData skill)
{
return ((Array)PlayerState["unlocked_skills"]).Contains(skill)
|| ((Array)CurrentSessionState["skills_unlocked"]).Contains(skill);
}
public void UnlockSkill(SkillData skill)
{
if (!IsSkillUnlocked(skill))
((Array)PlayerState["unlocked_skills"]).Add(skill);
}
public void RemoveSkill(string skillName)
{
var arr = (Array)PlayerState["unlocked_skills"];
foreach (SkillData s in arr)
{
if (s.Name != skillName) continue;
arr.Remove(s);
break;
}
}
public void UnlockSkills(Array<SkillData> skills)
{
foreach (var s in skills)
UnlockSkill(s);
}
public void ResetPlayerState()
{
PlayerState = new Dictionary
{
{ "coins", 0 },
{ "lives", 3 },
{ "current_level", 0 },
{ "completed_levels", new Array<int>() },
{ "unlocked_levels", new Array<int>() {0}},
{ "unlocked_skills", new Array<SkillData>() }
};
}
public void UnlockLevel(int levelIndex)
{
var unlocked = (Array)PlayerState["unlocked_levels"];
if (!unlocked.Contains(levelIndex)) unlocked.Add(levelIndex);
}
public void TryToGoToNextLevel()
{
var next = (int)PlayerState["current_level"] + 1;
var unlocked = (Array)PlayerState["unlocked_levels"];
if (next < LevelScenes.Count && unlocked.Contains(next))
{
PlayerState["current_level"] = next;
GetTree().ChangeSceneToPacked(LevelScenes[next]);
}
}
public void MarkLevelComplete(int levelIndex)
{
UnlockLevel(levelIndex + 1);
var completed = (Array)PlayerState["completed_levels"];
if (!completed.Contains(levelIndex)) completed.Add(levelIndex);
}
public void ResetCurrentSessionState()
{
CurrentSessionState = new Dictionary
{
{ "coins_collected", 0 },
{ "skills_unlocked", new Array<SkillData>() }
};
}
public void RestartGame()
{
ResetPlayerState();
ResetCurrentSessionState();
GetTree().ChangeSceneToPacked(LevelScenes[0]);
GetNode<SaveSystem>("/root/SaveSystem").SaveGame();
}
public void QuitGame() => GetTree().Quit();
public void PauseGame() => Engine.TimeScale = 0;
public void ResumeGame() => Engine.TimeScale = 1;
public void StartNewGame() public void StartNewGame()
{ {
ResetPlayerState(); if (Progression == null) return;
ResetCurrentSessionState();
GetTree().ChangeSceneToPacked(LevelScenes[0]); Progression.StartPlaythrough(ProgressionRoot.SoleSlot);
GetNode<SaveSystem>("/root/SaveSystem").SaveGame(); SpeedRunManager?.StartTimer();
EventBus.EmitGameStarted();
GoTo(Progression.Campaign.First);
} }
public void ContinueGame() public void ContinueGame()
{ {
var save = GetNode<SaveSystem>("/root/SaveSystem"); if (Progression == null) return;
if (!save.LoadGame())
if (!Progression.LoadPlaythrough(ProgressionRoot.SoleSlot))
{ {
GD.PrintErr("Failed to load game. Starting a new game instead."); var why = Progression.PeekPlaythrough(ProgressionRoot.SoleSlot);
if (why == LoadOutcome.Corrupt)
{
// Deliberately not overwritten: the file is left where it is so it can be recovered.
GD.PushError("Saved progress could not be read. It has been left untouched.");
return;
}
StartNewGame(); StartNewGame();
return; return;
} }
var idx = (int)PlayerState["current_level"]; SpeedRunManager?.StartTimer();
if (idx < LevelScenes.Count) EventBus.EmitGameContinued();
GetTree().ChangeSceneToPacked(LevelScenes[idx]);
else GoTo(Progression.CurrentLevel ?? Progression.Campaign.First);
GD.PrintErr("No levels unlocked to continue.");
} }
public void RestartGame()
{
if (Progression == null) return;
Progression.StartPlaythrough(ProgressionRoot.SoleSlot);
SpeedRunManager?.StartTimer();
GoTo(Progression.Campaign.First);
}
public void OnLevelComplete() public void OnLevelComplete()
{ {
var levelIndex = (int)PlayerState["current_level"]; if (Progression?.CurrentLevel is not { } finished) return;
MarkLevelComplete(levelIndex);
AddCoins((int)CurrentSessionState["coins_collected"]);
foreach (var s in (Array)CurrentSessionState["skills_unlocked"])
UnlockSkill((SkillData)s);
ResetCurrentSessionState(); var seconds = SpeedRunManager?.GetCurrentLevelTime() ?? 0.0;
TryToGoToNextLevel(); LevelTime? time = seconds > 0 ? LevelTime.Of(seconds) : null;
GetNode<SaveSystem>("/root/SaveSystem").SaveGame();
EventBus.EmitLevelCompleted(IndexOf(finished), GetTree().CurrentScene, seconds);
// Banks the attempt's coins and skills, marks the level cleared, and advances campaign
// position if this was a First Clear.
Progression.FinishLevel(time);
if (Progression.CurrentLevel is { } next && !next.Equals(finished))
GoTo(next);
} }
public Array<SkillData> GetUnlockedSkills() /// <summary>
/// Loads a Level's scene, then announces the entry once the new scene is actually current.
/// </summary>
/// <remarks>
/// The announcement is deliberately after a frame. <c>ChangeSceneToPacked</c> is deferred, so at
/// the moment it returns <c>CurrentScene</c> is still the outgoing scene — which is why the old
/// code handed <c>GhostEventHandler</c> the scene being torn down and ghosts were spawned into it.
/// </remarks>
private async void GoTo(LevelId level)
{ {
var unlocked = (Array<SkillData>)PlayerState["unlocked_skills"]; if (!Progression.CanEnter(level))
var session = (Array<SkillData>)CurrentSessionState["skills_unlocked"]; {
if (session!.Count == 0) return unlocked; GD.PushError($"Level '{level}' is not reachable in this playthrough.");
if (unlocked!.Count == 0) return session; return;
var joined = new Array<SkillData>(); }
joined.AddRange(unlocked);
joined.AddRange(session); var scene = Progression.Catalogue.SceneFor(level);
return joined; if (scene == null)
{
GD.PushError($"Level '{level}' has no loadable scene.");
return;
}
GetTree().ChangeSceneToPacked(scene);
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
Progression.EnterLevel(level);
} }
/// <summary>
/// Position in the catalogue, for the signals that are still index-based because GhostManager
/// names its files <c>ghost_level_{index}.json</c>.
/// </summary>
private int IndexOf(LevelId level)
{
var levels = Progression?.Catalogue?.Levels;
if (levels == null) return -1;
for (var i = 0; i < levels.Count; i++)
if (levels[i]?.Id == level.Value)
return i;
return -1;
}
public void PauseGame()
{
Engine.TimeScale = 0;
EventBus.EmitGamePaused();
}
public void ResumeGame()
{
Engine.TimeScale = 1;
EventBus.EmitGameResumed();
}
public void QuitGame() => GetTree().Quit();
#endregion
#region Player Lookup
public PlayerController GetPlayer() public PlayerController GetPlayer()
{ {
if (Player != null) return Player; if (_player != null && IsInstanceValid(_player)) return _player;
_player = null;
foreach (var node in _sceneNodes)
{
if (node is not PlayerController player) continue;
Player = player;
return Player;
}
GD.PrintErr("PlayerController not found in the scene tree.");
return null; return null;
} }
}
#endregion
}

243
Autoloads/GameStateStore.cs Normal file
View File

@@ -0,0 +1,243 @@
using Godot;
using System.Collections.Generic;
using Mr.BrickAdventures.scripts.Resources;
using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Central store for game state - single source of truth.
/// Use the static Instance property for easy access.
/// </summary>
public partial class GameStateStore : Node
{
/// <summary>
/// Singleton instance.
/// </summary>
public static GameStateStore Instance { get; private set; }
/// <summary>
/// Persistent player state (saved to disk).
/// </summary>
public PlayerState Player { get; set; } = new();
/// <summary>
/// Current session state (transient, reset on death/level complete).
/// </summary>
public SessionState Session { get; set; } = new();
public override void _Ready()
{
Instance = this;
}
public override void _ExitTree()
{
if (Instance == this)
Instance = null;
}
#region Coin Operations
/// <summary>
/// Gets total coins (saved + session).
/// </summary>
public int GetTotalCoins() => Player.Coins + Session.CoinsCollected;
/// <summary>
/// Adds coins to the session (not saved until level complete).
/// </summary>
public void AddSessionCoins(int amount)
{
Session.CoinsCollected += amount;
EventBus.EmitCoinsChanged(GetTotalCoins());
}
/// <summary>
/// Commits session coins to player state.
/// </summary>
public void CommitSessionCoins()
{
Player.Coins += Session.CoinsCollected;
Session.CoinsCollected = 0;
}
/// <summary>
/// Removes coins, first from session then from saved.
/// </summary>
public void RemoveCoins(int amount)
{
if (amount <= Session.CoinsCollected)
{
Session.CoinsCollected -= amount;
}
else
{
var remaining = amount - Session.CoinsCollected;
Session.CoinsCollected = 0;
Player.Coins = Mathf.Max(0, Player.Coins - remaining);
}
EventBus.EmitCoinsChanged(GetTotalCoins());
}
#endregion
#region Lives Operations
/// <summary>
/// Decrements lives by 1.
/// </summary>
public void RemoveLife()
{
Player.Lives = Mathf.Max(0, Player.Lives - 1);
EventBus.EmitLivesChanged(Player.Lives);
}
/// <summary>
/// Adds lives.
/// </summary>
public void AddLives(int amount)
{
Player.Lives += amount;
EventBus.EmitLivesChanged(Player.Lives);
}
#endregion
#region Level Operations
/// <summary>
/// Unlocks a level for access.
/// </summary>
public void UnlockLevel(int levelIndex)
{
if (!Player.UnlockedLevels.Contains(levelIndex))
Player.UnlockedLevels.Add(levelIndex);
}
/// <summary>
/// Marks a level as completed and unlocks the next.
/// </summary>
public void MarkLevelComplete(int levelIndex)
{
if (!Player.CompletedLevels.Contains(levelIndex))
Player.CompletedLevels.Add(levelIndex);
UnlockLevel(levelIndex + 1);
}
/// <summary>
/// Checks if a level is unlocked.
/// </summary>
public bool IsLevelUnlocked(int levelIndex) => Player.UnlockedLevels.Contains(levelIndex);
#endregion
#region Skill Operations
/// <summary>
/// Checks if a skill is unlocked (saved or session).
/// </summary>
public bool IsSkillUnlocked(SkillData skill)
{
return Player.UnlockedSkills.Contains(skill) || Session.SkillsUnlocked.Contains(skill);
}
/// <summary>
/// Unlocks a skill in the session (lost on death, committed on level complete).
/// </summary>
public void UnlockSkillInSession(SkillData skill)
{
if (!IsSkillUnlocked(skill))
Session.SkillsUnlocked.Add(skill);
}
/// <summary>
/// Permanently unlocks a skill directly in player state (bypasses session, e.g. console commands).
/// </summary>
public void UnlockSkillPermanently(SkillData skill)
{
if (!Player.UnlockedSkills.Contains(skill))
Player.UnlockedSkills.Add(skill);
}
/// <summary>
/// Removes a permanently unlocked skill from player state by name.
/// </summary>
public void RemoveUnlockedSkill(string skillName)
{
for (int i = 0; i < Player.UnlockedSkills.Count; i++)
{
if (Player.UnlockedSkills[i].Name == skillName)
{
Player.UnlockedSkills.RemoveAt(i);
return;
}
}
}
/// <summary>
/// Commits session skills to player state.
/// </summary>
public void CommitSessionSkills()
{
foreach (var skill in Session.SkillsUnlocked)
{
if (!Player.UnlockedSkills.Contains(skill))
Player.UnlockedSkills.Add(skill);
}
Session.SkillsUnlocked.Clear();
}
/// <summary>
/// Gets all unlocked skills from player persistence and current session.
/// </summary>
public List<SkillData> GetAllUnlockedSkills()
{
var result = new List<SkillData>(Player.UnlockedSkills);
foreach (var skill in Session.SkillsUnlocked)
{
if (!result.Contains(skill)) result.Add(skill);
}
return result;
}
#endregion
#region Statistics Operations
public void IncrementStat(string name, int amount = 1)
{
if (Player.Statistics.TryGetValue(name, out var current))
Player.Statistics[name] = current + amount;
else
Player.Statistics[name] = amount;
}
public void SetStat(string name, int value) => Player.Statistics[name] = value;
public int GetStat(string name) =>
Player.Statistics.TryGetValue(name, out var value) ? value : 0;
public System.Collections.Generic.Dictionary<string, int> GetAllStats() =>
new(Player.Statistics);
#endregion
#region Reset Operations
/// <summary>
/// Resets only the session state.
/// </summary>
public void ResetSession() => Session.Reset();
/// <summary>
/// Resets everything to defaults.
/// </summary>
public void ResetAll()
{
Player.Reset();
Session.ResetAll();
}
#endregion
}

View File

@@ -0,0 +1 @@
uid://bwrhkipwecytk

136
Autoloads/GhostManager.cs Normal file
View File

@@ -0,0 +1,136 @@
using System.Collections.Generic;
using System.Text.Json;
using Godot;
using Mr.BrickAdventures.scripts;
using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.Autoloads;
public partial class GhostManager : Node
{
public static GhostManager Instance { get; private set; }
[Export] private PackedScene GhostPlayerScene { get; set; }
public override void _Ready() => Instance = this;
public override void _ExitTree() { if (Instance == this) Instance = null; }
public bool IsRecording { get; private set; } = false;
public bool IsPlaybackEnabled { get; private set; } = true;
private List<GhostFrame> _currentRecording = [];
private double _startTime = 0.0;
private int _currentLevelIndex = -1;
public void StartRecording(int levelIndex)
{
if (!IsPlaybackEnabled) return;
_currentLevelIndex = levelIndex;
_currentRecording.Clear();
_startTime = Time.GetTicksMsec() / 1000.0;
IsRecording = true;
GD.Print("Ghost recording started.");
}
public void StopRecording(bool levelCompleted, double finalTime)
{
if (!IsRecording) return;
IsRecording = false;
if (levelCompleted)
{
var bestTime = LoadBestTime(_currentLevelIndex);
if (finalTime < bestTime)
{
SaveGhostData(_currentLevelIndex, finalTime);
GD.Print($"New best ghost saved for level {_currentLevelIndex}. Time: {finalTime}");
}
}
_currentRecording.Clear();
}
public void RecordFrame(Vector2 position)
{
if (!IsRecording) return;
_currentRecording.Add(new GhostFrame
{
Timestamp = (Time.GetTicksMsec() / 1000.0) - _startTime,
Position = position
});
}
public void SpawnGhostPlayer(int levelIndex, Node parent)
{
if (!IsPlaybackEnabled || GhostPlayerScene == null) return;
var ghostData = LoadGhostData(levelIndex);
if (ghostData.Count > 0)
{
var ghostPlayer = GhostPlayerScene.Instantiate<GhostPlayer>();
parent.AddChild(ghostPlayer);
ghostPlayer.StartPlayback(ghostData);
GD.Print($"Ghost player spawned for level {levelIndex}.");
}
}
private void SaveGhostData(int levelIndex, double time)
{
var path = $"user://ghost_level_{levelIndex}.json";
var saveData = new GhostSaveData { BestTime = time };
foreach (var frame in _currentRecording)
saveData.Frames.Add(new GhostFrameDto { Timestamp = frame.Timestamp, X = frame.Position.X, Y = frame.Position.Y });
try
{
var json = JsonSerializer.Serialize(saveData, SaveSystem.JsonOptions);
using var file = FileAccess.Open(path, FileAccess.ModeFlags.Write);
file.StoreString(json);
}
catch (System.Exception e)
{
GD.PrintErr($"GhostManager: Failed to save ghost data: {e.Message}");
}
}
private List<GhostFrame> LoadGhostData(int levelIndex)
{
var path = $"user://ghost_level_{levelIndex}.json";
if (!FileAccess.FileExists(path)) return [];
try
{
using var file = FileAccess.Open(path, FileAccess.ModeFlags.Read);
var saveData = JsonSerializer.Deserialize<GhostSaveData>(file.GetAsText(), SaveSystem.JsonOptions);
if (saveData == null) return [];
var frames = new List<GhostFrame>();
foreach (var dto in saveData.Frames)
frames.Add(new GhostFrame { Timestamp = dto.Timestamp, Position = new Vector2(dto.X, dto.Y) });
return frames;
}
catch (System.Exception e)
{
GD.PrintErr($"GhostManager: Failed to load ghost data: {e.Message}");
return [];
}
}
private double LoadBestTime(int levelIndex)
{
var path = $"user://ghost_level_{levelIndex}.json";
if (!FileAccess.FileExists(path)) return double.MaxValue;
try
{
using var file = FileAccess.Open(path, FileAccess.ModeFlags.Read);
var saveData = JsonSerializer.Deserialize<GhostSaveData>(file.GetAsText(), SaveSystem.JsonOptions);
return saveData?.BestTime ?? double.MaxValue;
}
catch
{
return double.MaxValue;
}
}
}

View File

@@ -0,0 +1 @@
uid://cgmuod4p2hg5h

View File

@@ -0,0 +1,54 @@
using Godot;
namespace Mr.BrickAdventures.Autoloads;
public partial class InputDeviceManager : Node
{
public enum InputDevice { Mouse, Keyboard, Gamepad }
public static InputDeviceManager Instance { get; private set; }
public InputDevice CurrentDevice { get; private set; } = InputDevice.Mouse;
public bool IsPointerless => CurrentDevice is InputDevice.Keyboard or InputDevice.Gamepad;
[Signal] public delegate void DeviceChangedEventHandler(int device);
public override void _Ready()
{
Instance = this;
Input.MouseMode = Input.MouseModeEnum.Visible;
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
}
public override void _Input(InputEvent @event)
{
InputDevice detected;
if (@event is InputEventMouseMotion or InputEventMouseButton)
detected = InputDevice.Mouse;
else if (@event is InputEventKey)
detected = InputDevice.Keyboard;
else if (@event is InputEventJoypadButton)
detected = InputDevice.Gamepad;
else if (@event is InputEventJoypadMotion joyEvent)
{
if (Mathf.Abs(joyEvent.AxisValue) <= 0.15f) return;
detected = InputDevice.Gamepad;
}
else
return;
if (detected == CurrentDevice) return;
CurrentDevice = detected;
Input.MouseMode = CurrentDevice == InputDevice.Mouse
? Input.MouseModeEnum.Visible
: Input.MouseModeEnum.Hidden;
EmitSignal(SignalName.DeviceChanged, (int)CurrentDevice);
}
}

View File

@@ -0,0 +1 @@
uid://b81610g8fd6p1

View File

@@ -0,0 +1,88 @@
using System.Collections.Generic;
using Godot;
using Mr.BrickAdventures.Progression;
using Mr.BrickAdventures.scripts.Resources;
namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Translates domain events into Godot signals on the <see cref="EventBus"/>.
/// </summary>
/// <remarks>
/// <para>
/// The adapter half of ADR-0004. Aggregates raise plain records with no engine dependency; this is the
/// single place that turns them into signals, which is what makes "state changed ⇒ signal fired"
/// un-bypassable. Existing subscribers and scene wiring keep working unchanged.
/// </para>
/// <para>
/// Not a Node — it has no lifecycle of its own. The composition root owns one and hands it every
/// batch of drained events.
/// </para>
/// </remarks>
public sealed class ProgressionEventRepublisher
{
private readonly LevelCatalogue _catalogue;
private readonly SceneTree _tree;
public ProgressionEventRepublisher(LevelCatalogue catalogue, SceneTree tree)
{
_catalogue = catalogue;
_tree = tree;
}
public void Publish(IReadOnlyList<ProgressionEvent> events)
{
foreach (var @event in events)
Publish(@event);
}
private void Publish(ProgressionEvent @event)
{
switch (@event)
{
case LevelStarted started:
// The existing signal is keyed by array index because GhostManager names its replay
// files ghost_level_{index}.json. Transitional: once ghosts key on LevelId this
// becomes a straight pass-through and the index lookup goes away.
EventBus.EmitLevelStarted(IndexOf(started.Level), _tree.CurrentScene);
break;
case RunEnded:
EventBus.EmitRunEnded();
break;
case AttemptFailed failed:
EventBus.EmitAttemptFailed(failed.Remaining.Value, failed.RunOver);
break;
case AchievementUnlocked unlocked:
EventBus.EmitAchievementUnlocked(unlocked.Achievement.Value);
break;
case LivesChanged lives:
EventBus.EmitLivesChanged(lives.Remaining.Value);
break;
case TotalCoinsChanged coins:
EventBus.EmitCoinsChanged(coins.Total.Value);
break;
case SkillSetChanged:
EventBus.EmitSkillSetChanged();
break;
default:
GD.PushWarning($"No signal is published for domain event {@event.GetType().Name}.");
break;
}
}
private int IndexOf(LevelId level)
{
for (var i = 0; i < _catalogue.Levels.Count; i++)
if (_catalogue.Levels[i]?.Id == level.Value)
return i;
return -1;
}
}

View File

@@ -0,0 +1 @@
uid://cjm0g50la7uie

View File

@@ -0,0 +1,219 @@
using System;
using Godot;
using Mr.BrickAdventures.Progression;
using Mr.BrickAdventures.Progression.Adapters;
using Mr.BrickAdventures.scripts.Resources;
namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Composition root for player progression: builds the stores, loads the Profile, and owns the
/// <see cref="ProgressionService"/>.
/// </summary>
/// <remarks>
/// <para>
/// The only Node in this area. The aggregates are plain objects, so their lifetime is no longer tied
/// to the scene tree — which is why the operations below need no null-guarding of the store itself.
/// </para>
/// <para>
/// Every mutating operation is wrapped so that draining and republishing cannot be forgotten. That is
/// the one risk of the DrainEvents design, and wrapping collapses it to this file.
/// </para>
/// </remarks>
public partial class ProgressionRoot : Node
{
public static ProgressionRoot Instance { get; private set; }
/// <summary>
/// The one slot the game currently uses. Phase B replaces this with a picker over several
/// Playthroughs; until then a fixed id keeps the file name stable across runs.
/// </summary>
public static readonly PlaythroughId SoleSlot =
PlaythroughId.Of(new Guid("a17e0000-0000-4000-8000-000000000001"));
[Export] public LevelCatalogue Catalogue { get; set; }
/// <summary>Lives a new Playthrough starts with. A tuning value, so it is authored here rather than baked into the domain.</summary>
[Export] public int StartingLives { get; set; } = 3;
private ProgressionService _service;
private ProgressionEventRepublisher _republisher;
/// <summary>Levels in campaign order, as the domain sees them.</summary>
public Campaign Campaign { get; private set; }
public override void _Ready()
{
Instance = this;
if (Catalogue == null)
{
GD.PushError("ProgressionRoot has no LevelCatalogue assigned; progression is unavailable.");
return;
}
foreach (var problem in Catalogue.Validate())
GD.PushError($"LevelCatalogue: {problem}");
Campaign = Catalogue.ToCampaign();
var directory = ProjectSettings.GlobalizePath("user://");
var playthroughs = new JsonPlaythroughStore(directory);
var profiles = new JsonProfileStore(directory);
_service = new ProgressionService(
LoadProfile(profiles), playthroughs, profiles,
Campaign, BuildSkillCatalogue(), Lives.Of(StartingLives));
_republisher = new ProgressionEventRepublisher(Catalogue, GetTree());
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
}
/// <summary>
/// A missing Profile is a first run. A corrupt one is reported loudly and replaced with an empty
/// Profile so the game still starts — but the file is left alone, so it can still be recovered.
/// </summary>
private static Profile LoadProfile(IProfileStore profiles)
{
var result = profiles.Load();
if (result.Outcome == LoadOutcome.Corrupt)
GD.PushError($"Profile could not be read and has been left untouched: {result.Problem}");
return result.Outcome == LoadOutcome.Loaded ? result.Value : Profile.Empty();
}
/// <summary>
/// Reads the authored upgrade tables into the domain's view of them. A Skill with no upgrade
/// entries yields an empty cost list, which the domain reports as not purchasable rather than
/// throwing on <c>Upgrades[0]</c>.
/// </summary>
private static SkillCatalogue BuildSkillCatalogue()
{
var available = SkillManager.Instance?.AvailableSkills;
if (available == null) return SkillCatalogue.Empty();
var costs = new System.Collections.Generic.Dictionary<SkillId, System.Collections.Generic.IReadOnlyList<Coins>>();
foreach (var skill in available)
{
if (skill == null || string.IsNullOrWhiteSpace(skill.Name)) continue;
var levels = new System.Collections.Generic.List<Coins>();
foreach (var upgrade in skill.Upgrades)
levels.Add(Coins.Of(Mathf.Max(0, upgrade.Cost)));
costs[SkillId.Of(skill.Name)] = levels;
}
return SkillCatalogue.Of(costs);
}
#region Operations — each drains and republishes, so callers cannot forget to
public void StartPlaythrough(PlaythroughId id) => Run(() => _service.StartPlaythrough(id));
public bool LoadPlaythrough(PlaythroughId id)
{
if (_service == null) return false;
var loaded = _service.LoadPlaythrough(id);
Publish();
return loaded;
}
public LoadOutcome PeekPlaythrough(PlaythroughId id) =>
_service?.PeekPlaythrough(id) ?? LoadOutcome.Absent;
public void RecordStat(StatName stat, int amount = 1) => _service?.RecordStat(stat, amount);
#endregion
#region Reads
public bool IsPlaying => _service?.IsPlaying ?? false;
public bool CanEnter(LevelId level) => _service?.CanEnter(level) ?? false;
public LevelId? CurrentLevel => _service?.CurrentLevel;
public Coins TotalCoins => _service?.TotalCoins ?? Coins.Zero;
public Lives Lives => _service?.Lives ?? Lives.Of(0);
public bool IsRunOver => _service?.IsRunOver ?? false;
public SkillLevel? LevelOf(SkillId skill) => _service?.LevelOf(skill);
public bool IsOwned(SkillId skill) => _service?.IsOwned(skill) ?? false;
public bool CanAfford(SkillId skill) => _service?.CanAfford(skill) ?? false;
public bool HasCleared(LevelId level) => _service?.HasCleared(level) ?? false;
#endregion
#region Operations, continued
public bool EnterLevel(LevelId level)
{
if (_service == null) return false;
var entered = _service.EnterLevel(level);
Publish();
return entered;
}
public void RecordCoinsFound(Coins amount) => Run(() => _service.RecordCoinsFound(amount));
public void HandlePlayerDeath() => Run(() => _service.HandlePlayerDeath());
public void FinishLevel(LevelTime? time) => Run(() => _service.FinishLevel(time));
public PurchaseOutcome PurchaseSkill(SkillId skill)
{
if (_service == null) return PurchaseOutcome.NotPlaying;
var outcome = _service.PurchaseSkill(skill);
Publish();
return outcome;
}
/// <summary>Free unlock, as a pickup gives. Returns false if it was already owned.</summary>
public bool GrantSkill(SkillId skill)
{
if (_service == null) return false;
var granted = _service.GrantSkill(skill);
Publish();
return granted;
}
/// <summary>Takes a Skill away — for Skills that consume themselves.</summary>
public bool RevokeSkill(SkillId skill)
{
if (_service == null) return false;
var revoked = _service.RevokeSkill(skill);
Publish();
return revoked;
}
/// <summary>Discards the current Attempt without charging a life — leaving a Level, not dying.</summary>
public void AbandonAttempt() => Run(() => _service.AbandonAttempt());
/// <summary>Console only — see <see cref="ProgressionService.ApplyCheat"/>.</summary>
public void ApplyCheat(Func<PlaythroughSnapshot, PlaythroughSnapshot> change) =>
Run(() => _service.ApplyCheat(change));
/// <summary>Whether there is a resumable Playthrough, for enabling the Continue button.</summary>
public bool HasSavedPlaythrough =>
PeekPlaythrough(SoleSlot) == LoadOutcome.Loaded;
private void Run(System.Action operation)
{
if (_service == null) return;
operation();
Publish();
}
private void Publish() => _republisher?.Publish(_service.DrainEvents());
#endregion
}

View File

@@ -0,0 +1 @@
uid://b6amqp58yjfc4

View File

@@ -1,61 +1,176 @@
using System.Collections.Generic;
using System.Text.Json;
using Godot; using Godot;
using Godot.Collections;
using Mr.BrickAdventures.scripts.Resources; using Mr.BrickAdventures.scripts.Resources;
using Mr.BrickAdventures.scripts.State;
namespace Mr.BrickAdventures.Autoloads; namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Save system that serializes state to JSON using DTOs.
/// </summary>
public partial class SaveSystem : Node public partial class SaveSystem : Node
{ {
[Export] public string SavePath { get; set; } = "user://savegame.save"; [Export] public string SavePath { get; set; } = "user://savegame.json";
[Export] public int Version { get; set; } = 1; [Export] public int Version { get; set; } = 2;
private GameManager _gameManager; public static SaveSystem Instance { get; private set; }
internal static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public override void _Ready() public override void _Ready()
{ {
_gameManager = GetNode<GameManager>("/root/GameManager"); Instance = this;
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
} }
public void SaveGame() public void SaveGame()
{ {
var saveData = new Dictionary var store = GameStateStore.Instance;
if (store == null)
{ {
{ "player_state", _gameManager.PlayerState}, GD.PrintErr("SaveSystem: GameStateStore not available.");
{ "version", Version} return;
}
// Convert to DTO (only serializable data)
var saveData = new SaveDataDto
{
Version = Version,
Coins = store.Player.Coins,
Lives = store.Player.Lives,
CurrentLevel = store.Player.CurrentLevel,
CompletedLevels = [.. store.Player.CompletedLevels],
UnlockedLevels = new List<int>(store.Player.UnlockedLevels),
UnlockedSkillNames = GetSkillNames(store.Player.UnlockedSkills),
UnlockedAchievements = new List<string>(store.Player.UnlockedAchievements),
Statistics = new Dictionary<string, int>(store.Player.Statistics)
}; };
using var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Write); try
file.StoreVar(saveData); {
GD.Print("Game state saved to: ", SavePath); var json = JsonSerializer.Serialize(saveData, JsonOptions);
using var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Write);
file.StoreString(json);
GD.Print("Game saved to: ", SavePath);
EventBus.EmitGameSaved();
}
catch (System.Exception e)
{
GD.PrintErr($"SaveSystem: Failed to save game: {e.Message}");
}
} }
public bool LoadGame() public bool LoadGame()
{ {
if (!FileAccess.FileExists(SavePath)) if (!FileAccess.FileExists(SavePath))
return false;
using var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Read);
var saveDataObj = (Dictionary)file.GetVar();
if (saveDataObj.ContainsKey("version") && (int)saveDataObj["version"] != Version)
{ {
GD.Print($"Save file version mismatch. Expected: {Version}, Found: {saveDataObj["version"]}"); GD.Print("SaveSystem: No save file found.");
return false; return false;
} }
GD.Print("Game state loaded from: ", SavePath); try
GD.Print("Player state: ", saveDataObj["player_state"]);
_gameManager.PlayerState = (Dictionary)saveDataObj["player_state"];
var skills = new Array<SkillData>();
foreach (var skill in (Array<SkillData>)_gameManager.PlayerState["unlocked_skills"])
{ {
skills.Add(skill); using var file = FileAccess.Open(SavePath, FileAccess.ModeFlags.Read);
var json = file.GetAsText();
var saveData = JsonSerializer.Deserialize<SaveDataDto>(json, JsonOptions);
if (saveData == null)
{
GD.PrintErr("SaveSystem: Failed to deserialize save data.");
return false;
}
if (saveData.Version != Version)
{
GD.PrintErr($"SaveSystem: Version mismatch. Expected {Version}, found {saveData.Version}");
return false;
}
var store = GameStateStore.Instance;
if (store == null)
{
GD.PrintErr("SaveSystem: GameStateStore not available.");
return false;
}
// Apply loaded state
store.Player.Coins = saveData.Coins;
store.Player.Lives = saveData.Lives;
store.Player.CurrentLevel = saveData.CurrentLevel;
store.Player.CompletedLevels = saveData.CompletedLevels ?? new List<int>();
store.Player.UnlockedLevels = saveData.UnlockedLevels ?? new List<int> { 0 };
store.Player.UnlockedAchievements = saveData.UnlockedAchievements ?? new List<string>();
store.Player.Statistics = saveData.Statistics ?? new Dictionary<string, int>();
// Reload skills by name from SkillManager
store.Player.UnlockedSkills = LoadSkillsByName(saveData.UnlockedSkillNames);
GD.Print("Game loaded from: ", SavePath);
return true;
}
catch (System.Exception e)
{
GD.PrintErr($"SaveSystem: Failed to load game: {e.Message}");
return false;
} }
_gameManager.UnlockSkills(skills);
return true;
} }
private static List<string> GetSkillNames(List<SkillData> skills)
{
var names = new List<string>();
foreach (var skill in skills)
{
if (skill != null)
names.Add(skill.Name);
}
return names;
}
private List<SkillData> LoadSkillsByName(List<string> skillNames)
{
var skills = new List<SkillData>();
if (skillNames == null) return skills;
var skillManager = SkillManager.Instance;
if (skillManager == null)
{
GD.PrintErr("SaveSystem: SkillManager not available to resolve skill names.");
return skills;
}
foreach (var name in skillNames)
{
var skill = skillManager.GetSkillByName(name);
if (skill != null)
{
skills.Add(skill);
}
else
{
GD.PrintErr($"SaveSystem: Skill '{name}' not found in SkillManager.");
}
}
return skills;
}
public bool CheckSaveExists() => FileAccess.FileExists(SavePath); public bool CheckSaveExists() => FileAccess.FileExists(SavePath);
}
public void DeleteSave()
{
if (FileAccess.FileExists(SavePath))
{
DirAccess.RemoveAbsolute(ProjectSettings.GlobalizePath(SavePath));
GD.Print("Save file deleted.");
}
}
}

View File

@@ -0,0 +1,203 @@
using Godot;
using System.Collections.Generic;
namespace Mr.BrickAdventures.Autoloads;
public partial class SettingsManager : Node
{
public static SettingsManager Instance { get; private set; }
public float GamepadDeadzone { get; set; } = 0.2f;
public float GamepadSensitivity { get; set; } = 1.0f;
public string WindowMode { get; set; } = "fullscreen";
public Vector2I Resolution { get; set; } = new Vector2I(1920, 1080);
private static readonly List<Vector2I> CuratedResolutions = new()
{
// 4:3
new Vector2I(640, 480),
new Vector2I(800, 600),
new Vector2I(1024, 768),
new Vector2I(1280, 960),
new Vector2I(1600, 1200),
// 16:9
new Vector2I(1280, 720),
new Vector2I(1366, 768),
new Vector2I(1600, 900),
new Vector2I(1920, 1080),
new Vector2I(2560, 1440),
new Vector2I(3840, 2160),
// 16:10
new Vector2I(1280, 800),
new Vector2I(1440, 900),
new Vector2I(1920, 1200),
new Vector2I(2560, 1600),
};
private readonly List<Vector2I> _customResolutions = new();
private static readonly string[] DeadzoneActions = { "left", "right", "up", "down" };
public override void _Ready()
{
Instance = this;
if (ConfigFileHandler.Instance == null)
{
GD.PushError("SettingsManager: ConfigFileHandler.Instance is null");
return;
}
var cfg = ConfigFileHandler.Instance.SettingsConfig;
// --- display_settings ---
WindowMode = cfg.GetValue("display_settings", "window_mode", Variant.From("fullscreen")).AsString();
var resStr = cfg.GetValue("display_settings", "resolution", Variant.From("1920x1080")).AsString();
Resolution = ParseResolution(resStr, new Vector2I(1920, 1080));
var customResStr = cfg.GetValue("display_settings", "custom_resolutions", Variant.From("")).AsString();
if (!string.IsNullOrWhiteSpace(customResStr))
{
foreach (var part in customResStr.Split(','))
{
var r = ParseResolution(part.Trim(), Vector2I.Zero);
if (r != Vector2I.Zero)
_customResolutions.Add(r);
}
}
ApplyDisplaySettings();
// --- gameplay_settings ---
GamepadDeadzone = (float)cfg.GetValue("gameplay_settings", "gamepad_deadzone", Variant.From(0.2)).AsDouble();
GamepadSensitivity = (float)cfg.GetValue("gameplay_settings", "gamepad_sensitivity", Variant.From(1.0)).AsDouble();
ApplyGamepadDeadzone();
// --- input_settings ---
ApplyInputBindings(cfg);
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
}
// ── public API ───────────────────────────────────────────────────────────
public List<Vector2I> GetAllResolutions()
{
var all = new List<Vector2I>(CuratedResolutions);
all.AddRange(_customResolutions);
return all;
}
public void SaveDisplaySettings()
{
var cfg = ConfigFileHandler.Instance.SettingsConfig;
cfg.SetValue("display_settings", "window_mode", WindowMode);
cfg.SetValue("display_settings", "resolution", $"{Resolution.X}x{Resolution.Y}");
cfg.SetValue("display_settings", "custom_resolutions", BuildCustomResolutionsString());
cfg.Save(ConfigFileHandler.SettingsPath);
}
public void SaveGameplaySettings()
{
var cfg = ConfigFileHandler.Instance.SettingsConfig;
cfg.SetValue("gameplay_settings", "gamepad_deadzone", (double)GamepadDeadzone);
cfg.SetValue("gameplay_settings", "gamepad_sensitivity", (double)GamepadSensitivity);
cfg.Save(ConfigFileHandler.SettingsPath);
}
public void SaveInputSettings()
{
var cfg = ConfigFileHandler.Instance.SettingsConfig;
foreach (var action in InputMap.GetActions())
{
if (action.ToString().StartsWith("ui_")) continue;
foreach (var ev in InputMap.ActionGetEvents(action))
{
if (ev is InputEventKey key)
{
cfg.SetValue("input_settings", action, (long)key.PhysicalKeycode);
break;
}
}
}
cfg.Save(ConfigFileHandler.SettingsPath);
}
// ── apply helpers ────────────────────────────────────────────────────────
public void ApplyDisplaySettings()
{
switch (WindowMode)
{
case "fullscreen":
DisplayServer.WindowSetMode(DisplayServer.WindowMode.Fullscreen);
break;
case "borderless":
DisplayServer.WindowSetMode(DisplayServer.WindowMode.Windowed);
DisplayServer.WindowSetFlag(DisplayServer.WindowFlags.Borderless, true);
DisplayServer.WindowSetSize(Resolution);
break;
default: // "windowed"
DisplayServer.WindowSetMode(DisplayServer.WindowMode.Windowed);
DisplayServer.WindowSetFlag(DisplayServer.WindowFlags.Borderless, false);
DisplayServer.WindowSetSize(Resolution);
break;
}
}
public void ApplyGamepadDeadzone()
{
foreach (var action in DeadzoneActions)
{
if (InputMap.HasAction(action))
InputMap.ActionSetDeadzone(action, GamepadDeadzone);
}
}
private static void ApplyInputBindings(ConfigFile cfg)
{
if (!cfg.HasSection("input_settings")) return;
foreach (var actionName in cfg.GetSectionKeys("input_settings"))
{
if (actionName.StartsWith("ui_")) continue;
if (!InputMap.HasAction(actionName)) continue;
var scancode = (Key)(long)cfg.GetValue("input_settings", actionName);
// Remove existing keyboard events for this action
var events = InputMap.ActionGetEvents(actionName);
foreach (var ev in events)
{
if (ev is InputEventKey)
InputMap.ActionEraseEvent(actionName, ev);
}
// Add the saved key
var newKey = new InputEventKey { PhysicalKeycode = scancode };
InputMap.ActionAddEvent(actionName, newKey);
}
}
// ── util ─────────────────────────────────────────────────────────────────
private static Vector2I ParseResolution(string s, Vector2I fallback)
{
var parts = s.Split('x');
if (parts.Length == 2 && int.TryParse(parts[0], out var w) && int.TryParse(parts[1], out var h))
return new Vector2I(w, h);
return fallback;
}
private string BuildCustomResolutionsString()
{
var parts = new List<string>();
foreach (var r in _customResolutions)
parts.Add($"{r.X}x{r.Y}");
return string.Join(",", parts);
}
}

View File

@@ -0,0 +1 @@
uid://d4jyxtm4jgtka

216
Autoloads/SkillManager.cs Normal file
View File

@@ -0,0 +1,216 @@
using System.Linq;
using Godot;
using Godot.Collections;
using Mr.BrickAdventures.Progression;
using Mr.BrickAdventures.scripts.components;
using Mr.BrickAdventures.scripts.interfaces;
using Mr.BrickAdventures.scripts.Resources;
namespace Mr.BrickAdventures.Autoloads;
/// <summary>
/// Owns the live skill components attached to the player, and keeps them matching what the Playthrough
/// says is owned.
/// </summary>
/// <remarks>
/// <para>
/// Reconciles from <see cref="EventBus.SkillSetChanged"/> rather than being told by each caller. The old
/// arrangement had four call sites doing "record it in the store, then tell SkillManager" as two
/// separate calls — anything between them, including <c>AddSkill</c>'s own early return when no player
/// is registered, left a skill owned but inert.
/// </para>
/// <para>
/// Tracks the level each component was built at, so an upgrade rebuilds it. Previously
/// <c>AddSkill</c> returned early for an already-active skill, and the upgrade was applied by the
/// purchase code reaching into the component instead.
/// </para>
/// </remarks>
public partial class SkillManager : Node
{
private PlayerController _player;
[Export] public Array<SkillData> AvailableSkills { get; set; } = [];
public Dictionary ActiveComponents { get; private set; } = new();
private readonly System.Collections.Generic.Dictionary<string, int> _activeLevels = new();
public static SkillManager Instance { get; private set; }
[Signal]
public delegate void ActiveThrowSkillChangedEventHandler(BrickThrowComponent throwComponent);
[Signal]
public delegate void SkillRemovedEventHandler(SkillData skillData);
public override void _Ready()
{
Instance = this;
EventBus.Instance.SkillSetChanged += Reconcile;
}
public override void _ExitTree()
{
if (EventBus.Instance != null)
EventBus.Instance.SkillSetChanged -= Reconcile;
if (Instance == this) Instance = null;
}
/// <summary>
/// Called by the PlayerController from its _Ready method to register itself with the manager.
/// </summary>
public void RegisterPlayer(PlayerController player)
{
if (_player == player) return;
if (_player != null && IsInstanceValid(_player))
UnregisterPlayer();
_player = player;
if (_player != null)
{
_player.TreeExiting += UnregisterPlayer;
Reconcile();
}
}
private void UnregisterPlayer()
{
if (_player != null && IsInstanceValid(_player))
{
_player.TreeExiting -= UnregisterPlayer;
RemoveAllActiveSkills();
}
_player = null;
}
/// <summary>
/// Brings the live components in line with what is owned: adds what is missing, rebuilds anything
/// whose level changed, removes what is no longer owned.
/// </summary>
public void Reconcile()
{
if (_player == null || !IsInstanceValid(_player)) return;
var progression = ProgressionRoot.Instance;
if (progression == null) return;
foreach (var skill in AvailableSkills)
{
if (skill == null || string.IsNullOrWhiteSpace(skill.Name)) continue;
var owned = progression.LevelOf(SkillId.Of(skill.Name))?.Value;
var active = _activeLevels.TryGetValue(skill.Name, out var at) ? at : (int?)null;
if (owned == active) continue;
if (active is not null) RemoveSkill(skill.Name);
if (owned is not null) AddSkill(skill, owned.Value);
}
}
/// <summary>Kept for the marketplace's activate/deactivate toggle, which is a display concern.</summary>
public void AddSkill(SkillData skillData) =>
AddSkill(skillData, ProgressionRoot.Instance?.LevelOf(SkillId.Of(skillData.Name))?.Value ?? 1);
private void AddSkill(SkillData skillData, int level)
{
if (_player == null || !IsInstanceValid(_player))
{
GD.Print("SkillManager: Player not available to add skill.");
return;
}
if (ActiveComponents.ContainsKey(skillData.Name))
return;
if (skillData.Type == SkillType.Throw)
{
foreach (var sd in AvailableSkills)
{
if (sd != skillData && sd?.Type == SkillType.Throw)
RemoveSkill(sd.Name);
}
}
var instance = skillData.Node.Instantiate();
if (instance is ISkill skill)
{
skill.Initialize(_player, skillData, level);
skill.Activate();
}
else
{
GD.PrintErr($"Skill scene for '{skillData.Name}' does not implement ISkill!");
instance.QueueFree();
return;
}
_player.AddChild(instance);
ActiveComponents[skillData.Name] = instance;
_activeLevels[skillData.Name] = level;
if (instance is BrickThrowComponent btc)
EmitSignalActiveThrowSkillChanged(btc);
}
public void RemoveSkill(string skillName)
{
_activeLevels.Remove(skillName);
if (!ActiveComponents.TryGetValue(skillName, out var component))
return;
if (component.AsGodotObject() is BrickThrowComponent)
EmitSignalActiveThrowSkillChanged(null);
var inst = (Node)component;
if (inst is ISkill skill)
skill.Deactivate();
if (IsInstanceValid(inst))
inst.QueueFree();
ActiveComponents.Remove(skillName);
var sd = GetSkillByName(skillName);
if (sd != null) EmitSignalSkillRemoved(sd);
}
private void RemoveAllActiveSkills()
{
foreach (var skillName in ActiveComponents.Keys.Select(k => k.ToString()).ToList())
RemoveSkill(skillName);
}
public SkillData GetSkillByName(string skillName)
{
foreach (var sd in AvailableSkills)
if (sd?.Name == skillName) return sd;
return null;
}
public bool IsSkillActive(SkillData skill) =>
skill != null && ActiveComponents.ContainsKey(skill.Name);
public void ActivateSkill(SkillData skill)
{
if (!ActiveComponents.ContainsKey(skill.Name))
AddSkill(skill);
}
public void DeactivateSkill(SkillData skill)
{
if (ActiveComponents.ContainsKey(skill.Name))
RemoveSkill(skill.Name);
}
public void ToggleSkillActivation(SkillData skill)
{
if (skill == null) return;
if (ActiveComponents.ContainsKey(skill.Name))
DeactivateSkill(skill);
else
ActivateSkill(skill);
}
}

View File

@@ -0,0 +1 @@
uid://dru77vj07e18s

View File

@@ -0,0 +1,66 @@
using System.Collections.Generic;
using Godot;
namespace Mr.BrickAdventures.Autoloads;
public partial class SpeedRunManager : Node
{
public static SpeedRunManager Instance { get; private set; }
public override void _EnterTree() => Instance = this;
public override void _ExitTree() { if (Instance == this) Instance = null; }
public bool IsRunning { get; private set; } = false;
private double _startTime;
private double _levelStartTime;
private List<double> _splits = [];
[Signal] public delegate void TimeUpdatedEventHandler(double totalTime, double levelTime);
public override void _Process(double delta)
{
if (!IsRunning) return;
EmitSignalTimeUpdated(GetCurrentTotalTime(), GetCurrentLevelTime());
}
public void StartTimer()
{
_startTime = Time.GetTicksMsec() / 1000.0;
_levelStartTime = _startTime;
_splits.Clear();
IsRunning = true;
GD.Print("Speedrun timer started.");
}
public void StopTimer()
{
if (!IsRunning) return;
IsRunning = false;
var finalTime = GetCurrentTotalTime();
GD.Print($"Speedrun finished. Final time: {FormatTime(finalTime)}");
// Save personal best if applicable
}
public void Split()
{
if (!IsRunning) return;
var now = Time.GetTicksMsec() / 1000.0;
var splitTime = now - _levelStartTime;
_splits.Add(splitTime);
_levelStartTime = now;
GD.Print($"Split recorded: {FormatTime(splitTime)}");
}
public double GetCurrentTotalTime() => IsRunning ? (Time.GetTicksMsec() / 1000.0) - _startTime : 0;
public double GetCurrentLevelTime() => IsRunning ? (Time.GetTicksMsec() / 1000.0) - _levelStartTime : 0;
public static string FormatTime(double time)
{
var span = System.TimeSpan.FromSeconds(time);
return $"{(int)span.TotalMinutes:00}:{span.Seconds:00}.{span.Milliseconds:000}";
}
}

View File

@@ -0,0 +1 @@
uid://c6ohe36xw1h21

77
Autoloads/SteamManager.cs Normal file
View File

@@ -0,0 +1,77 @@
using System;
using Godot;
using Steamworks;
using Steamworks.Data;
namespace Mr.BrickAdventures.Autoloads;
public partial class SteamManager : Node
{
private const uint AppId = 3575090;
public static string PlayerName { get; private set; } = "Player";
public static bool IsSteamInitialized { get; private set; } = false;
public override void _Ready()
{
try
{
SteamClient.Init(AppId);
IsSteamInitialized = true;
PlayerName = SteamClient.Name;
GD.Print($"Steam initialized successfully for user: {PlayerName}");
}
catch (Exception e)
{
GD.PushError("Failed to initialize Steamworks. Is Steam running?");
GD.PushError(e.Message);
IsSteamInitialized = false;
}
}
public override void _Process(double delta)
{
if (IsSteamInitialized) SteamClient.RunCallbacks();
}
public override void _Notification(int what)
{
if (what == NotificationWMCloseRequest)
{
if (IsSteamInitialized)
{
SteamClient.Shutdown();
}
GetTree().Quit();
}
}
public static void UnlockAchievement(string achievementId)
{
if (!IsSteamInitialized)
{
GD.Print($"Steam not initialized. Cannot unlock achievement '{achievementId}'.");
return;
}
var ach = new Achievement(achievementId);
if (ach.State)
{
GD.Print($"Achievement '{achievementId}' is already unlocked.");
return;
}
if (ach.Trigger())
{
SteamUserStats.StoreStats();
GD.Print($"Successfully triggered achievement: {ach.Name} ({ach.Identifier})");
}
else
{
GD.PrintErr($"Failed to trigger achievement: {achievementId}");
}
}
}

View File

@@ -0,0 +1 @@
uid://b5abjlnbia63q

View File

@@ -1,11 +1,22 @@
using Godot; using Godot;
using Godot.Collections;
namespace Mr.BrickAdventures.Autoloads; namespace Mr.BrickAdventures.Autoloads;
public partial class UIManager : Node public partial class UIManager : Node
{ {
[Export] public Array<Control> UiStack { get; set; } = new(); public static UIManager Instance { get; private set; }
private readonly System.Collections.Generic.List<Control> UiStack = new();
public override void _Ready()
{
Instance = this;
}
public override void _ExitTree()
{
if (Instance == this) Instance = null;
}
[Signal] public delegate void ScreenPushedEventHandler(Control screen); [Signal] public delegate void ScreenPushedEventHandler(Control screen);
[Signal] public delegate void ScreenPoppedEventHandler(Control screen); [Signal] public delegate void ScreenPoppedEventHandler(Control screen);
@@ -34,19 +45,19 @@ public partial class UIManager : Node
return; return;
} }
var top = (Control)UiStack[^1]; var top = UiStack[^1];
UiStack.RemoveAt(UiStack.Count - 1); UiStack.RemoveAt(UiStack.Count - 1);
top.Hide(); top.Hide();
top.SetProcessInput(false); top.SetProcessInput(false);
EmitSignalScreenPopped(top); EmitSignalScreenPopped(top);
top.AcceptEvent(); top.AcceptEvent();
if (UiStack.Count > 0) ((Control)UiStack[^1]).GrabFocus(); if (UiStack.Count > 0) UiStack[^1].GrabFocus();
} }
public Control TopScreen() => UiStack.Count > 0 ? (Control)UiStack[^1] : null; public Control TopScreen() => UiStack.Count > 0 ? UiStack[^1] : null;
public bool IsScreenOnTop(Control screen) => UiStack.Count > 0 && (Control)UiStack[^1] == screen; public bool IsScreenOnTop(Control screen) => UiStack.Count > 0 && UiStack[^1] == screen;
public bool IsVisibleOnStack(Control screen) => UiStack.Contains(screen) && screen.Visible; public bool IsVisibleOnStack(Control screen) => UiStack.Contains(screen) && screen.Visible;

103
CONTEXT.md Normal file
View File

@@ -0,0 +1,103 @@
# Mr. Brick Adventures
A 2D platformer. This context covers **player state and progression** — what the player has, what they have done,
and at what point each of those becomes permanent. Gameplay behaviour (movement, combat, hazards) lives in the
composable components and is deliberately outside this context.
## Language
### Scopes
Three lifetimes. Every fact in this context belongs to exactly one.
**Attempt**:
One try at a level, from entering it to either completing it or losing a life. Holds a ledger of everything that
happened during the try — coins found, coins spent, skills bought. Discarded whole if the player dies.
_Avoid_: Session, run, life
**Playthrough**:
One game from New Game until game over, identified by a `PlaythroughId`. Holds lives, banked coins, owned skills
and their levels, campaign position, and which levels it has cleared. Several exist at once and the player picks
between them.
_Avoid_: Save, save slot, progress, game, run
**Profile**:
Everything shared across all Playthroughs, one per installation. Achievements, best time per Level, and lifetime
statistics. Never reset by starting a new Playthrough.
_Avoid_: Global state, meta, persistent data, account
### Operations
**Settle**:
Resolving an ended Attempt against the Playthrough — Commit it on a First Clear, Discard it otherwise. Settling is
the only way an Attempt affects a Playthrough, and it decides First Clear versus Replay itself rather than being
told which one happened.
_Avoid_: Apply, resolve, finalise
**Commit**:
Applying an Attempt's ledger to the Playthrough. Only a First Clear commits — nothing else, ever.
_Avoid_: Save, persist, flush, sync
**First Clear**:
Completing a Level this Playthrough has not cleared before. The only event that commits an Attempt and advances
campaign position.
_Avoid_: Level complete, finish (both ambiguous — they also describe a Replay)
**Replay**:
Completing a Level this Playthrough has already cleared. Records Best Time, achievements and statistics to the
Profile; commits nothing and never advances campaign position.
_Avoid_: Practice, retry, second run
**Bank**:
The coins a Playthrough holds, as opposed to coins found in the current Attempt. Coins are banked by committing,
never on pickup.
_Avoid_: Total coins, saved coins
**Discard**:
Throwing away an Attempt's ledger — on death, or at the end of a Replay. Purchases made during the Attempt are
undone by discarding, not refunded; under the ledger they never happened.
_Avoid_: Reset, rollback, revert
### Things
**Level**:
One playable stage, identified by a stable `LevelId` — never by position in a list. Knows its scene and display
name. Ids must survive inserting and reordering levels, because Playthroughs and Best Times both reference them.
_Avoid_: Stage, scene, level index
**Campaign**:
The authored ordering of Levels that defines progression — which Level is first, and what follows a given one. One
Campaign is shared by every Playthrough: it is authored data, not per-Playthrough state, and it carries no scene
or presentation detail.
_Avoid_: Level list, level order, world, chapter
**Cleared Levels**:
The Levels a Playthrough has completed at least once. Gates both campaign progression and level select; whether a
Level is reachable is computed from this, never stored alongside it.
_Avoid_: Unlocked levels, completed levels, available levels
**Skill**:
An ability the player can own and upgrade. `SkillData` is the authored asset describing one (name, icon, upgrade
table) and is immutable at runtime; the level the player has reached in it belongs to the Playthrough.
_Avoid_: Ability, power-up, upgrade (for the skill itself)
**Movement Ability**:
A composable behaviour attached to the player controller (jump, wall-jump, gravity). Unrelated to Skill —
authored per movement preset, not owned, not upgraded, not persisted.
_Avoid_: Skill, movement skill
**Best Time**:
The fastest recorded completion of a Level, from a First Clear or a Replay alike. Belongs to the Profile, so it
survives New Game and is shared by every Playthrough. Stored alongside, but distinct from, the ghost replay
frames that produced it.
_Avoid_: Personal best, high score, record
## Rules
- Reads may cross scopes freely — affordability spans Attempt and Playthrough. Writes must not.
- Derived facts are computed, never stored. Level reachability is derived from Cleared Levels; total coins from
Attempt plus Bank. Storing either alongside its source creates the second source of truth this context exists
to prevent.
- Achievements are mirrored to Steam, which is per-account and cannot be un-earned. That is why they are Profile
and not Playthrough: a per-Playthrough achievement list would contradict Steam permanently.
- Nothing in this context may reference Godot. Its correctness must be checkable without a scene tree.

View File

@@ -1,143 +1,25 @@
<Project Sdk="Godot.NET.Sdk/4.4.1"> <Project Sdk="Godot.NET.Sdk/4.7.1">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading> <EnableDynamicLoading>true</EnableDynamicLoading>
<RootNamespace>Mr.BrickAdventures</RootNamespace> <RootNamespace>Mr.BrickAdventures</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Content Include="README.md" /> <PackageReference Include="Facepunch.Steamworks" Version="2.3.3" />
<Content Include="scripts\components\.idea\.gitignore" /> <PackageReference Include="Facepunch.Steamworks.Dlls" Version="2.3.2" />
<Content Include="scripts\components\.idea\encodings.xml" /> <PackageReference Include="Facepunch.Steamworks.Library" Version="2.3.3" />
<Content Include="scripts\components\.idea\indexLayout.xml" /> </ItemGroup>
<Content Include="scripts\components\.idea\inspectionProfiles\Project_Default.xml" />
<Content Include="scripts\components\.idea\projectSettingsUpdater.xml" /> <!-- Godot.NET.Sdk globs **/*.cs, which would compile the progression projects into this
<Content Include="scripts\components\.idea\vcs.xml" /> assembly as well as their own. They are separate projects; exclude them here. -->
<Content Include="scripts\components\.idea\workspace.xml" /> <ItemGroup>
<Content Include="scripts\components\BeamComponent.cs.uid" /> <Compile Remove="progression\**" />
<Content Include="scripts\components\beam_component.gd" /> <Compile Remove="progression.tests\**" />
<Content Include="scripts\components\beam_component.gd.uid" /> <Compile Remove="progression.adapters\**" />
<Content Include="scripts\components\brick_throw.gd" /> </ItemGroup>
<Content Include="scripts\components\brick_throw.gd.uid" />
<Content Include="scripts\components\bullet_component.gd" /> <ItemGroup>
<Content Include="scripts\components\bullet_component.gd.uid" /> <ProjectReference Include="progression\Mr.BrickAdventures.Progression.csproj" />
<Content Include="scripts\components\cage_component.gd" /> <ProjectReference Include="progression.adapters\Mr.BrickAdventures.Progression.Adapters.csproj" />
<Content Include="scripts\components\cage_component.gd.uid" />
<Content Include="scripts\components\cannot_stomp_component.gd" />
<Content Include="scripts\components\cannot_stomp_component.gd.uid" />
<Content Include="scripts\components\can_be_launched_component.gd" />
<Content Include="scripts\components\can_be_launched_component.gd.uid" />
<Content Include="scripts\components\can_pickup.gd" />
<Content Include="scripts\components\can_pickup.gd.uid" />
<Content Include="scripts\components\charge_throw_component.gd" />
<Content Include="scripts\components\charge_throw_component.gd.uid" />
<Content Include="scripts\components\chase_level_component.gd" />
<Content Include="scripts\components\chase_level_component.gd.uid" />
<Content Include="scripts\components\cleanup_component.gd" />
<Content Include="scripts\components\cleanup_component.gd.uid" />
<Content Include="scripts\components\collapsable.gd" />
<Content Include="scripts\components\collapsable.gd.uid" />
<Content Include="scripts\components\collectable.gd" />
<Content Include="scripts\components\collectable.gd.uid" />
<Content Include="scripts\components\damage_component.gd" />
<Content Include="scripts\components\damage_component.gd.uid" />
<Content Include="scripts\components\destroyable_component.gd" />
<Content Include="scripts\components\destroyable_component.gd.uid" />
<Content Include="scripts\components\effect_inflictor_component.gd" />
<Content Include="scripts\components\effect_inflictor_component.gd.uid" />
<Content Include="scripts\components\enemy_death.gd" />
<Content Include="scripts\components\enemy_death.gd.uid" />
<Content Include="scripts\components\enemy_wave_trigger.gd" />
<Content Include="scripts\components\enemy_wave_trigger.gd.uid" />
<Content Include="scripts\components\exit_door_component.gd" />
<Content Include="scripts\components\exit_door_component.gd.uid" />
<Content Include="scripts\components\explosive_component.gd" />
<Content Include="scripts\components\explosive_component.gd.uid" />
<Content Include="scripts\components\fade_away.gd" />
<Content Include="scripts\components\fade_away.gd.uid" />
<Content Include="scripts\components\fire_effect_component.gd" />
<Content Include="scripts\components\fire_effect_component.gd.uid" />
<Content Include="scripts\components\flashing_component.gd" />
<Content Include="scripts\components\flashing_component.gd.uid" />
<Content Include="scripts\components\flip_player.gd" />
<Content Include="scripts\components\flip_player.gd.uid" />
<Content Include="scripts\components\gravity_motion_component.gd" />
<Content Include="scripts\components\gravity_motion_component.gd.uid" />
<Content Include="scripts\components\health.gd" />
<Content Include="scripts\components\health.gd.uid" />
<Content Include="scripts\components\heal_component.gd" />
<Content Include="scripts\components\heal_component.gd.uid" />
<Content Include="scripts\components\hit_component.gd" />
<Content Include="scripts\components\hit_component.gd.uid" />
<Content Include="scripts\components\homing_missile_motion.gd" />
<Content Include="scripts\components\homing_missile_motion.gd.uid" />
<Content Include="scripts\components\ice_effect_component.gd" />
<Content Include="scripts\components\ice_effect_component.gd.uid" />
<Content Include="scripts\components\invulnerability_component.gd" />
<Content Include="scripts\components\invulnerability_component.gd.uid" />
<Content Include="scripts\components\jump_pad_component.gd" />
<Content Include="scripts\components\jump_pad_component.gd.uid" />
<Content Include="scripts\components\kill_player_out_of_screen.gd" />
<Content Include="scripts\components\kill_player_out_of_screen.gd.uid" />
<Content Include="scripts\components\knockback.gd" />
<Content Include="scripts\components\knockback.gd.uid" />
<Content Include="scripts\components\launch_component.gd" />
<Content Include="scripts\components\launch_component.gd.uid" />
<Content Include="scripts\components\lever_component.gd" />
<Content Include="scripts\components\lever_component.gd.uid" />
<Content Include="scripts\components\lifetime_component.gd" />
<Content Include="scripts\components\lifetime_component.gd.uid" />
<Content Include="scripts\components\magnetic_skill.gd" />
<Content Include="scripts\components\magnetic_skill.gd.uid" />
<Content Include="scripts\components\out_of_screen_component.gd" />
<Content Include="scripts\components\out_of_screen_component.gd.uid" />
<Content Include="scripts\components\periodic_shooting.gd" />
<Content Include="scripts\components\periodic_shooting.gd.uid" />
<Content Include="scripts\components\PlatformMovementComponent.cs.uid" />
<Content Include="scripts\components\platform_movement.gd" />
<Content Include="scripts\components\PlayerController.cs.uid" />
<Content Include="scripts\components\player_death.gd" />
<Content Include="scripts\components\player_death.gd.uid" />
<Content Include="scripts\components\player_movement.gd" />
<Content Include="scripts\components\player_movement.gd.uid" />
<Content Include="scripts\components\progressive_damage_component.gd" />
<Content Include="scripts\components\progressive_damage_component.gd.uid" />
<Content Include="scripts\components\projectile_component.gd" />
<Content Include="scripts\components\projectile_component.gd.uid" />
<Content Include="scripts\components\projectile_init_component.gd" />
<Content Include="scripts\components\projectile_init_component.gd.uid" />
<Content Include="scripts\components\requirement_component.gd" />
<Content Include="scripts\components\requirement_component.gd.uid" />
<Content Include="scripts\components\score.gd" />
<Content Include="scripts\components\score.gd.uid" />
<Content Include="scripts\components\ship_movement.gd" />
<Content Include="scripts\components\ship_movement.gd.uid" />
<Content Include="scripts\components\ship_shooter.gd" />
<Content Include="scripts\components\ship_shooter.gd.uid" />
<Content Include="scripts\components\side_to_side_movement.gd" />
<Content Include="scripts\components\side_to_side_movement.gd.uid" />
<Content Include="scripts\components\skill_unlocker_component.gd" />
<Content Include="scripts\components\skill_unlocker_component.gd.uid" />
<Content Include="scripts\components\spaceship_enter_component.gd" />
<Content Include="scripts\components\spaceship_enter_component.gd.uid" />
<Content Include="scripts\components\spaceship_exit_component.gd" />
<Content Include="scripts\components\spaceship_exit_component.gd.uid" />
<Content Include="scripts\components\spin_component.gd" />
<Content Include="scripts\components\spin_component.gd.uid" />
<Content Include="scripts\components\status_effect_component.gd" />
<Content Include="scripts\components\status_effect_component.gd.uid" />
<Content Include="scripts\components\stomp_damage_component.gd" />
<Content Include="scripts\components\stomp_damage_component.gd.uid" />
<Content Include="scripts\components\straight_motion_component.gd" />
<Content Include="scripts\components\straight_motion_component.gd.uid" />
<Content Include="scripts\components\terrain_hit_fx.gd" />
<Content Include="scripts\components\terrain_hit_fx.gd.uid" />
<Content Include="scripts\components\tooltip_component.gd" />
<Content Include="scripts\components\tooltip_component.gd.uid" />
<Content Include="scripts\components\trail_component.gd" />
<Content Include="scripts\components\trail_component.gd.uid" />
<Content Include="scripts\components\trigger_lever_component.gd" />
<Content Include="scripts\components\trigger_lever_component.gd.uid" />
<Content Include="scripts\components\unlock_on_requirement_component.gd" />
<Content Include="scripts\components\unlock_on_requirement_component.gd.uid" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,19 +1,139 @@
Microsoft Visual Studio Solution File, Format Version 12.00 
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 2012 # Visual Studio 2012
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr. Brick Adventures", "Mr. Brick Adventures.csproj", "{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr. Brick Adventures", "Mr. Brick Adventures.csproj", "{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "progression", "progression", "{73CE8E92-AE0D-34EB-F3CD-A6392CC7A1AA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr.BrickAdventures.Progression", "progression\Mr.BrickAdventures.Progression.csproj", "{4F115C37-24F6-4933-9086-ED5925EE5106}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "progression.tests", "progression.tests", "{B756B2BA-9E41-468E-DBBE-5BA8CC0A1E41}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr.BrickAdventures.Progression.Tests", "progression.tests\Mr.BrickAdventures.Progression.Tests.csproj", "{A4660883-E42A-4D04-99A3-770F36E9D861}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "progression.adapters", "progression.adapters", "{9D5C8041-6C54-0A01-82AE-FC7E0DE079DA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mr.BrickAdventures.Progression.Adapters", "progression.adapters\Mr.BrickAdventures.Progression.Adapters.csproj", "{045D9BF4-8E63-4F14-84F4-F8C55073244C}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
ExportDebug|Any CPU = ExportDebug|Any CPU Debug|x64 = Debug|x64
ExportRelease|Any CPU = ExportRelease|Any CPU Debug|x86 = Debug|x86
ExportDebug|Any CPU = ExportDebug|Any CPU
ExportDebug|x64 = ExportDebug|x64
ExportDebug|x86 = ExportDebug|x86
ExportRelease|Any CPU = ExportRelease|Any CPU
ExportRelease|x64 = ExportRelease|x64
ExportRelease|x86 = ExportRelease|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|x64.ActiveCfg = Debug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|x64.Build.0 = Debug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Debug|x86.Build.0 = Debug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU {A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU {A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|x64.ActiveCfg = ExportDebug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|x64.Build.0 = ExportDebug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|x86.ActiveCfg = ExportDebug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportDebug|x86.Build.0 = ExportDebug|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU {A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU {A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|x64.ActiveCfg = ExportRelease|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|x64.Build.0 = ExportRelease|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|x86.ActiveCfg = ExportRelease|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.ExportRelease|x86.Build.0 = ExportRelease|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|Any CPU.Build.0 = Release|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|x64.ActiveCfg = Release|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|x64.Build.0 = Release|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|x86.ActiveCfg = Release|Any CPU
{A1D482B9-207B-4D6C-A0A0-D9E6D1AE2356}.Release|x86.Build.0 = Release|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|x64.ActiveCfg = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|x64.Build.0 = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|x86.ActiveCfg = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Debug|x86.Build.0 = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|Any CPU.ActiveCfg = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|Any CPU.Build.0 = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|x64.ActiveCfg = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|x64.Build.0 = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|x86.ActiveCfg = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportDebug|x86.Build.0 = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|Any CPU.ActiveCfg = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|Any CPU.Build.0 = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|x64.ActiveCfg = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|x64.Build.0 = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|x86.ActiveCfg = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.ExportRelease|x86.Build.0 = Debug|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|Any CPU.Build.0 = Release|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|x64.ActiveCfg = Release|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|x64.Build.0 = Release|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|x86.ActiveCfg = Release|Any CPU
{4F115C37-24F6-4933-9086-ED5925EE5106}.Release|x86.Build.0 = Release|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|x64.ActiveCfg = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|x64.Build.0 = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|x86.ActiveCfg = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Debug|x86.Build.0 = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|Any CPU.ActiveCfg = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|Any CPU.Build.0 = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|x64.ActiveCfg = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|x64.Build.0 = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|x86.ActiveCfg = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportDebug|x86.Build.0 = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|Any CPU.ActiveCfg = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|Any CPU.Build.0 = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|x64.ActiveCfg = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|x64.Build.0 = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|x86.ActiveCfg = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.ExportRelease|x86.Build.0 = Debug|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|Any CPU.Build.0 = Release|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x64.ActiveCfg = Release|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x64.Build.0 = Release|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x86.ActiveCfg = Release|Any CPU
{A4660883-E42A-4D04-99A3-770F36E9D861}.Release|x86.Build.0 = Release|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|x64.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|x64.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|x86.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Debug|x86.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|Any CPU.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|Any CPU.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|x64.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|x64.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|x86.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportDebug|x86.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|Any CPU.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|Any CPU.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|x64.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|x64.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|x86.ActiveCfg = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.ExportRelease|x86.Build.0 = Debug|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|Any CPU.Build.0 = Release|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|x64.ActiveCfg = Release|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|x64.Build.0 = Release|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|x86.ActiveCfg = Release|Any CPU
{045D9BF4-8E63-4F14-84F4-F8C55073244C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{4F115C37-24F6-4933-9086-ED5925EE5106} = {73CE8E92-AE0D-34EB-F3CD-A6392CC7A1AA}
{A4660883-E42A-4D04-99A3-770F36E9D861} = {B756B2BA-9E41-468E-DBBE-5BA8CC0A1E41}
{045D9BF4-8E63-4F14-84F4-F8C55073244C} = {9D5C8041-6C54-0A01-82AE-FC7E0DE079DA}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View File

@@ -1,6 +1,17 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AArea2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F116d8c5f8dae51522ba398e1d89e3d4722f4af7b6e7f071196b928be44af7_003FArea2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACamera2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fa2e12a1a67ad701a97608de6be85250e3e353951ecf8058a02c703490c753_003FCamera2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACanvasItem_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fef7b819b226fab796d1dfe66d415dd7510bcac87675020ddb8f03a828e763_003FCanvasItem_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACanvasItem_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fef7b819b226fab796d1dfe66d415dd7510bcac87675020ddb8f03a828e763_003FCanvasItem_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACecovym_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003Ftmp_003FJetBrainsPerUserTemp_002D1000_002D1_003FSandboxFiles_003FSadijuw_003FCecovym_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACharacterBody2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbba0bbd7a98ee58286e9484fbe86e01afff6232283f6efd3556eb7116453_003FCharacterBody2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACollisionShape2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F2ca9b7334678f5c97c7c2a9fbe4837be71cae11b6a30408dd4791b18f997e4a_003FCollisionShape2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACollisionShape2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F2ca9b7334678f5c97c7c2a9fbe4837be71cae11b6a30408dd4791b18f997e4a_003FCollisionShape2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACSharpInstanceBridge_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F94701b444afa4c3d9a7a53ebcaa35fd1583c00_003F14_003F4b4ade3f_003FCSharpInstanceBridge_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADelegateUtils_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F94701b444afa4c3d9a7a53ebcaa35fd1583c00_003F08_003F45f75e10_003FDelegateUtils_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADictionary_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F94701b444afa4c3d9a7a53ebcaa35fd1583c00_003F47_003Fda1d8d31_003FDictionary_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AList_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fe747192abb38e2df82cbdb37e721567726f559914a7b81f8b26ba537de632f4_003FList_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMr_002EBrickAdventures_002Escripts_002Ecomponents_002ECollectableComponent_005FScriptSignals_002Egenerated_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F80d9408eb7280c15eb4a12b61cdf8f7f1b0c5a2_003FMr_002EBrickAdventures_002Escripts_002Ecomponents_002ECollectableComponent_005FScriptSignals_002Egenerated_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANode2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F86db9cd834346aad02d74c1b66dd9c64d6ef3147435dd9c9c9477b48f7_003FNode2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANode2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F86db9cd834346aad02d74c1b66dd9c64d6ef3147435dd9c9c9477b48f7_003FNode2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APhysicsBody2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F4fcd28ddaffa68e6fe53288161b788dea7d402b4a41b9d9f0f0f2c52f9af075_003FPhysicsBody2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARectangleShape2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fa1cc98873548652da0c14ecefa4737431426fcbb24a7f0641e3d9c266c3_003FRectangleShape2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARectangleShape2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fa1cc98873548652da0c14ecefa4737431426fcbb24a7f0641e3d9c266c3_003FRectangleShape2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARigidBody2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F7bc3ccc1ac5bbc68933d64c7b7eb5ab4aecde2b73c686dd6495b68bdf08ba5b2_003FRigidBody2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AShape2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F3671dbbd9b17cdf2bf9075b468b6bd7e3ab13fc3be7a116484085d3b6cc9fe_003FShape2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AShape2D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F3671dbbd9b17cdf2bf9075b468b6bd7e3ab13fc3be7a116484085d3b6cc9fe_003FShape2D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>

View File

@@ -0,0 +1,13 @@
[gd_resource type="Resource" script_class="AchievementResource" load_steps=3 format=3 uid="uid://3odfkm1ig5"]
[ext_resource type="Texture2D" uid="uid://cebeyr4wnibvk" path="res://sprites/achievement.png" id="1_usw25"]
[ext_resource type="Script" uid="uid://duib5phrmpro5" path="res://scripts/Resources/AchievementResource.cs" id="2_n7ktn"]
[resource]
script = ExtResource("2_n7ktn")
Id = "level_complete_1"
DisplayName = "Complete level 1"
Description = ""
Icon = ExtResource("1_usw25")
IsSecret = false
metadata/_custom_type_script = "uid://duib5phrmpro5"

View File

@@ -0,0 +1,131 @@
using System;
namespace Mr.BrickAdventures.Tools.CaLevelGenerator;
public static class CaGenerator
{
public static bool[,] Generate(CaGeneratorSettings s)
{
var rng = new Random(s.Seed);
return s.Mode switch
{
CaMode.Cave => GenerateCave(s, rng),
CaMode.Platform => GeneratePlatform(s, rng),
CaMode.Terrain => GenerateTerrain(s, rng),
_ => GenerateCave(s, rng),
};
}
public static bool[,] Smooth(bool[,] grid) => SmoothPass(grid, 5);
// ── Cave ──────────────────────────────────────────────────────────────
private static bool[,] GenerateCave(CaGeneratorSettings s, Random rng)
{
var grid = RandomFill(s.Width, s.Height, s.FillDensity, rng);
if (s.BorderWalls) EnforceBorder(grid);
for (int i = 0; i < s.SmoothingPasses; i++)
{
grid = SmoothPass(grid, 5);
if (s.BorderWalls) EnforceBorder(grid);
}
return grid;
}
// ── Platform ──────────────────────────────────────────────────────────
private static bool[,] GeneratePlatform(CaGeneratorSettings s, Random rng)
{
int w = s.Width, h = s.Height;
var grid = new bool[w, h];
for (int x = 0; x < w; x++)
{
grid[x, h - 1] = true;
grid[x, h - 2] = true;
for (int y = 0; y < h - 2; y++)
{
// Density linearly increases from 10% at top to 60% at bottom,
// scaled by user's FillDensity relative to default 0.48.
float t = (float)y / (h - 3);
float rowDensity = (0.1f + t * 0.5f) * (s.FillDensity / 0.48f);
grid[x, y] = rng.NextDouble() < rowDensity;
}
}
for (int i = 0; i < s.SmoothingPasses; i++)
{
// Threshold 4 (not 5): horizontal platform groups survive, isolated
// vertical pixels collapse into flat platforms.
grid = SmoothPass(grid, 4);
for (int x = 0; x < w; x++) { grid[x, h - 1] = true; grid[x, h - 2] = true; }
}
return grid;
}
// ── Terrain ───────────────────────────────────────────────────────────
private static bool[,] GenerateTerrain(CaGeneratorSettings s, Random rng)
{
int w = s.Width, h = s.Height;
var grid = new bool[w, h];
// Start terrain surface at ~60% from bottom (40% from top)
int surfaceRow = (int)(h * 0.4f);
for (int x = 0; x < w; x++)
{
int delta = rng.Next(-2, 3);
surfaceRow = Math.Clamp(surfaceRow + delta, h / 6, h - 2);
for (int y = surfaceRow; y < h; y++)
grid[x, y] = true;
}
for (int i = 0; i < s.SmoothingPasses; i++)
grid = SmoothPass(grid, 5);
return grid;
}
// ── Shared helpers ────────────────────────────────────────────────
private static bool[,] RandomFill(int w, int h, float density, Random rng)
{
var grid = new bool[w, h];
for (int x = 0; x < w; x++)
for (int y = 0; y < h; y++)
grid[x, y] = rng.NextDouble() < density;
return grid;
}
private static bool[,] SmoothPass(bool[,] grid, int threshold)
{
int w = grid.GetLength(0), h = grid.GetLength(1);
var next = new bool[w, h];
for (int x = 0; x < w; x++)
for (int y = 0; y < h; y++)
next[x, y] = CountSolidNeighbors(grid, x, y) >= threshold;
return next;
}
private static int CountSolidNeighbors(bool[,] grid, int cx, int cy)
{
int w = grid.GetLength(0), h = grid.GetLength(1), count = 0;
for (int dx = -1; dx <= 1; dx++)
for (int dy = -1; dy <= 1; dy++)
{
if (dx == 0 && dy == 0) continue;
int nx = cx + dx, ny = cy + dy;
// Out-of-bounds counts as solid (closes cave edges naturally)
if (nx < 0 || nx >= w || ny < 0 || ny >= h) count++;
else if (grid[nx, ny]) count++;
}
return count;
}
private static void EnforceBorder(bool[,] grid)
{
int w = grid.GetLength(0), h = grid.GetLength(1);
for (int x = 0; x < w; x++) { grid[x, 0] = true; grid[x, h - 1] = true; }
for (int y = 0; y < h; y++) { grid[0, y] = true; grid[w - 1, y] = true; }
}
}

View File

@@ -0,0 +1 @@
uid://exr10b7nmkgn

View File

@@ -0,0 +1,23 @@
using Godot;
namespace Mr.BrickAdventures.Tools.CaLevelGenerator;
public enum CaMode { Cave, Platform, Terrain }
public class CaGeneratorSettings
{
public CaMode Mode { get; set; } = CaMode.Cave;
public int Width { get; set; } = 40;
public int Height { get; set; } = 22;
public Vector2I Offset { get; set; } = Vector2I.Zero;
public float FillDensity { get; set; } = 0.48f;
public int SmoothingPasses { get; set; } = 4;
public bool BorderWalls { get; set; } = true;
public int Seed { get; set; } = 12345;
// TerrainSet == -1 → raw tile mode
public int TerrainSet { get; set; } = 0;
public int Terrain { get; set; } = 0;
// Used only when TerrainSet == -1
public int SourceId { get; set; } = 0;
public Vector2I AtlasCoords { get; set; } = Vector2I.Zero;
}

View File

@@ -0,0 +1 @@
uid://isnscgr1thvc

View File

@@ -0,0 +1,79 @@
#if TOOLS
using Godot;
namespace Mr.BrickAdventures.Tools.CaLevelGenerator;
[Tool]
public partial class CaGeneratorTestRunner : Node
{
public override void _Ready()
{
int pass = 0, fail = 0;
void Assert(bool cond, string msg)
{
if (cond) { GD.Print($" PASS: {msg}"); pass++; }
else { GD.PrintErr($" FAIL: {msg}"); fail++; }
}
GD.Print("=== CaGenerator Tests ===");
// Grid dimensions
var s = new CaGeneratorSettings { Width = 20, Height = 15, SmoothingPasses = 0, Seed = 42 };
var g = CaGenerator.Generate(s);
Assert(g.GetLength(0) == 20, "Width == 20");
Assert(g.GetLength(1) == 15, "Height == 15");
// Determinism
var g2 = CaGenerator.Generate(s);
bool det = true;
for (int x = 0; x < 20 && det; x++)
for (int y = 0; y < 15 && det; y++)
if (g[x, y] != g2[x, y]) det = false;
Assert(det, "Same seed → same output");
// Different seeds → different output
var s2 = new CaGeneratorSettings { Width = 20, Height = 15, SmoothingPasses = 0, Seed = 99 };
var g3 = CaGenerator.Generate(s2);
bool diff = false;
for (int x = 0; x < 20 && !diff; x++)
for (int y = 0; y < 15 && !diff; y++)
if (g[x, y] != g3[x, y]) diff = true;
Assert(diff, "Different seeds → different output");
// Cave border walls
var sb = new CaGeneratorSettings { Width = 20, Height = 15, BorderWalls = true, SmoothingPasses = 0, Seed = 42 };
var gb = CaGenerator.Generate(sb);
bool border = true;
for (int x = 0; x < 20; x++) if (!gb[x, 0] || !gb[x, 14]) { border = false; break; }
for (int y = 0; y < 15; y++) if (!gb[0, y] || !gb[19, y]) { border = false; break; }
Assert(border, "Cave: border cells all solid");
// Platform floor
var sp = new CaGeneratorSettings { Mode = CaMode.Platform, Width = 20, Height = 15, SmoothingPasses = 0, Seed = 42 };
var gp = CaGenerator.Generate(sp);
bool floor = true;
for (int x = 0; x < 20; x++) if (!gp[x, 14] || !gp[x, 13]) { floor = false; break; }
Assert(floor, "Platform: bottom 2 rows solid");
// Platform: top row not all solid (mostly empty at top)
int topSolid = 0;
for (int x = 0; x < 20; x++) if (gp[x, 0]) topSolid++;
Assert(topSolid < 15, "Platform: top row mostly empty");
// Terrain: bottom always solid
var st = new CaGeneratorSettings { Mode = CaMode.Terrain, Width = 20, Height = 15, SmoothingPasses = 0, Seed = 42 };
var gt = CaGenerator.Generate(st);
bool terrBottom = true;
for (int x = 0; x < 20; x++) if (!gt[x, 14]) { terrBottom = false; break; }
Assert(terrBottom, "Terrain: bottom row always solid");
// Smooth: output same dimensions
var smoothed = CaGenerator.Smooth(g);
Assert(smoothed.GetLength(0) == 20, "Smooth: width preserved");
Assert(smoothed.GetLength(1) == 15, "Smooth: height preserved");
GD.Print($"=== Results: {pass} passed, {fail} failed ===");
}
}
#endif

View File

@@ -0,0 +1 @@
uid://6y4snwf31boh

View File

@@ -0,0 +1,360 @@
#if TOOLS
using Godot;
namespace Mr.BrickAdventures.Tools.CaLevelGenerator;
[Tool]
public partial class CaLevelGeneratorDock : Control
{
public CaLevelGeneratorPlugin Plugin { get; set; }
// Controls referenced in Task 8 wiring
private OptionButton _layerDropdown;
private OptionButton _terrainDropdown;
private HBoxContainer _rawTileRow;
private SpinBox _sourceIdSpin;
private SpinBox _atlasXSpin;
private SpinBox _atlasYSpin;
private Button _caveBtn;
private Button _platformBtn;
private Button _terrainBtn;
private SpinBox _widthSpin;
private SpinBox _heightSpin;
private SpinBox _offsetXSpin;
private SpinBox _offsetYSpin;
private HSlider _densitySlider;
private Label _densityLabel;
private HSlider _smoothPassSlider;
private Label _smoothPassLabel;
private CheckBox _borderCheck;
private SpinBox _seedSpin;
private Button _genBtn;
private Button _smoothBtn;
private Button _clearBtn;
private CaMode _mode = CaMode.Cave;
private System.Collections.Generic.List<TileMapLayer> _layers = new();
private TileMapLayer _selectedLayer;
public override void _Ready()
{
CustomMinimumSize = new Vector2(200, 0);
var scroll = new ScrollContainer();
scroll.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
scroll.SizeFlagsVertical = SizeFlags.ExpandFill;
AddChild(scroll);
var vbox = new VBoxContainer();
vbox.SizeFlagsHorizontal = SizeFlags.ExpandFill;
scroll.AddChild(vbox);
// Target Layer
vbox.AddChild(new Label { Text = "Target Layer" });
_layerDropdown = new OptionButton();
_layerDropdown.SizeFlagsHorizontal = SizeFlags.ExpandFill;
vbox.AddChild(_layerDropdown);
// Terrain
vbox.AddChild(new Label { Text = "Terrain" });
_terrainDropdown = new OptionButton();
_terrainDropdown.SizeFlagsHorizontal = SizeFlags.ExpandFill;
vbox.AddChild(_terrainDropdown);
// Raw tile row (hidden when terrain is available)
_rawTileRow = new HBoxContainer();
_rawTileRow.Visible = false;
_rawTileRow.AddChild(new Label { Text = "Src" });
_sourceIdSpin = new SpinBox { MinValue = 0, MaxValue = 99, Value = 0 };
_rawTileRow.AddChild(_sourceIdSpin);
_rawTileRow.AddChild(new Label { Text = "X" });
_atlasXSpin = new SpinBox { MinValue = 0, MaxValue = 99, Value = 0 };
_rawTileRow.AddChild(_atlasXSpin);
_rawTileRow.AddChild(new Label { Text = "Y" });
_atlasYSpin = new SpinBox { MinValue = 0, MaxValue = 99, Value = 0 };
_rawTileRow.AddChild(_atlasYSpin);
vbox.AddChild(_rawTileRow);
// Mode toggle
vbox.AddChild(new Label { Text = "Mode" });
var modeRow = new HBoxContainer();
modeRow.SizeFlagsHorizontal = SizeFlags.ExpandFill;
_caveBtn = MakeModeButton("Cave", () => SetMode(CaMode.Cave));
_platformBtn = MakeModeButton("Platform", () => SetMode(CaMode.Platform));
_terrainBtn = MakeModeButton("Terrain", () => SetMode(CaMode.Terrain));
_caveBtn.ButtonPressed = true;
modeRow.AddChild(_caveBtn);
modeRow.AddChild(_platformBtn);
modeRow.AddChild(_terrainBtn);
vbox.AddChild(modeRow);
vbox.AddChild(new HSeparator());
// Width / Height
var dimRow = new HBoxContainer();
dimRow.AddChild(MakeSpinColumn("Width", out _widthSpin, 5, 500, 40));
dimRow.AddChild(MakeSpinColumn("Height", out _heightSpin, 5, 500, 22));
vbox.AddChild(dimRow);
// Offset X / Y
var offRow = new HBoxContainer();
offRow.AddChild(MakeSpinColumn("Offset X", out _offsetXSpin, -9999, 9999, 0));
offRow.AddChild(MakeSpinColumn("Offset Y", out _offsetYSpin, -9999, 9999, 0));
vbox.AddChild(offRow);
// Fill Density
var densHeader = new HBoxContainer();
densHeader.AddChild(new Label { Text = "Fill Density", SizeFlagsHorizontal = SizeFlags.ExpandFill });
_densityLabel = new Label { Text = "48%" };
densHeader.AddChild(_densityLabel);
vbox.AddChild(densHeader);
_densitySlider = new HSlider { MinValue = 0, MaxValue = 100, Value = 48, Step = 1 };
_densitySlider.ValueChanged += v => _densityLabel.Text = $"{(int)v}%";
vbox.AddChild(_densitySlider);
// Smoothing Passes
var smoothHeader = new HBoxContainer();
smoothHeader.AddChild(new Label { Text = "Smoothing Passes", SizeFlagsHorizontal = SizeFlags.ExpandFill });
_smoothPassLabel = new Label { Text = "4" };
smoothHeader.AddChild(_smoothPassLabel);
vbox.AddChild(smoothHeader);
_smoothPassSlider = new HSlider { MinValue = 0, MaxValue = 10, Value = 4, Step = 1 };
_smoothPassSlider.ValueChanged += v => _smoothPassLabel.Text = $"{(int)v}";
vbox.AddChild(_smoothPassSlider);
// Border walls
_borderCheck = new CheckBox { Text = "Solid border walls", ButtonPressed = true };
vbox.AddChild(_borderCheck);
// Seed
vbox.AddChild(new Label { Text = "Seed" });
var seedRow = new HBoxContainer();
_seedSpin = new SpinBox { MinValue = 0, MaxValue = int.MaxValue, Value = 12345,
AllowGreater = true, SizeFlagsHorizontal = SizeFlags.ExpandFill };
var randBtn = new Button { Text = "🎲" };
randBtn.Pressed += () => _seedSpin.Value = GD.Randi();
seedRow.AddChild(_seedSpin);
seedRow.AddChild(randBtn);
vbox.AddChild(seedRow);
vbox.AddChild(new HSeparator());
// Action buttons
_genBtn = new Button { Text = "▶ Generate", SizeFlagsHorizontal = SizeFlags.ExpandFill };
vbox.AddChild(_genBtn);
var actionRow = new HBoxContainer();
actionRow.SizeFlagsHorizontal = SizeFlags.ExpandFill;
_smoothBtn = new Button { Text = "✦ Smooth", SizeFlagsHorizontal = SizeFlags.ExpandFill };
_clearBtn = new Button { Text = "✕ Clear", SizeFlagsHorizontal = SizeFlags.ExpandFill };
actionRow.AddChild(_smoothBtn);
actionRow.AddChild(_clearBtn);
vbox.AddChild(actionRow);
_layerDropdown.ItemSelected += OnLayerSelected;
_terrainDropdown.ItemSelected += _ => SaveSettings();
_genBtn.Pressed += OnGenerate;
_smoothBtn.Pressed += OnSmooth;
_clearBtn.Pressed += OnClear;
LoadSettings();
}
public void OnSceneChanged(Node sceneRoot)
{
ScanLayers(sceneRoot);
RestoreLastLayer(sceneRoot?.SceneFilePath ?? "");
}
private void SetMode(CaMode mode)
{
_mode = mode;
_caveBtn.ButtonPressed = mode == CaMode.Cave;
_platformBtn.ButtonPressed = mode == CaMode.Platform;
_terrainBtn.ButtonPressed = mode == CaMode.Terrain;
}
private static Button MakeModeButton(string text, System.Action onPressed)
{
var btn = new Button { Text = text, ToggleMode = true,
SizeFlagsHorizontal = SizeFlags.ExpandFill };
btn.Pressed += onPressed;
return btn;
}
private static Control MakeSpinColumn(string label, out SpinBox spin,
double min, double max, double value)
{
var col = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
col.AddChild(new Label { Text = label });
spin = new SpinBox { MinValue = min, MaxValue = max, Value = value,
SizeFlagsHorizontal = SizeFlags.ExpandFill };
col.AddChild(spin);
return col;
}
private void ScanLayers(Node root)
{
_layers.Clear();
_layerDropdown.Clear();
if (root == null) return;
CollectLayers(root);
foreach (var layer in _layers)
_layerDropdown.AddItem(layer.Name);
if (_layers.Count > 0) OnLayerSelected(0);
}
private void CollectLayers(Node node)
{
if (node is TileMapLayer layer) _layers.Add(layer);
foreach (Node child in node.GetChildren()) CollectLayers(child);
}
private void OnLayerSelected(long index)
{
if (index < 0 || index >= _layers.Count) return;
_selectedLayer = _layers[(int)index];
PopulateTerrainDropdown(_selectedLayer);
var sceneRoot = EditorInterface.Singleton.GetEditedSceneRoot();
if (sceneRoot != null)
{
var key = $"ca_generator/last_layer_{sceneRoot.SceneFilePath}";
EditorInterface.Singleton.GetEditorSettings()
.SetSetting(key, _selectedLayer.GetPath().ToString());
}
SaveSettings();
}
private void RestoreLastLayer(string sceneFilePath)
{
if (string.IsNullOrEmpty(sceneFilePath) || _layers.Count == 0) return;
var key = $"ca_generator/last_layer_{sceneFilePath}";
var es = EditorInterface.Singleton.GetEditorSettings();
if (!es.HasSetting(key)) return;
var path = es.GetSetting(key).AsString();
for (int i = 0; i < _layers.Count; i++)
{
if (_layers[i].GetPath().ToString() == path)
{
_layerDropdown.Select(i);
OnLayerSelected(i);
return;
}
}
}
private void PopulateTerrainDropdown(TileMapLayer layer)
{
_terrainDropdown.Clear();
var tileSet = layer.TileSet;
bool hasTerrains = false;
if (tileSet != null)
{
for (int ts = 0; ts < tileSet.GetTerrainSetsCount(); ts++)
{
for (int t = 0; t < tileSet.GetTerrainsCount(ts); t++)
{
_terrainDropdown.AddItem(tileSet.GetTerrainName(ts, t));
_terrainDropdown.SetItemMetadata(
_terrainDropdown.ItemCount - 1,
Variant.From((ts << 16) | t));
hasTerrains = true;
}
}
}
_terrainDropdown.Visible = hasTerrains;
_rawTileRow.Visible = !hasTerrains;
}
private CaGeneratorSettings BuildSettings()
{
int terrainSet = -1, terrain = 0;
if (_terrainDropdown.Visible && _terrainDropdown.ItemCount > 0)
{
int packed = _terrainDropdown.GetSelectedMetadata().AsInt32();
terrainSet = packed >> 16;
terrain = packed & 0xFFFF;
}
return new CaGeneratorSettings
{
Mode = _mode,
Width = (int)_widthSpin.Value,
Height = (int)_heightSpin.Value,
Offset = new Vector2I((int)_offsetXSpin.Value, (int)_offsetYSpin.Value),
FillDensity = (float)_densitySlider.Value / 100f,
SmoothingPasses = (int)_smoothPassSlider.Value,
BorderWalls = _borderCheck.ButtonPressed,
Seed = (int)_seedSpin.Value,
TerrainSet = terrainSet,
Terrain = terrain,
SourceId = (int)_sourceIdSpin.Value,
AtlasCoords = new Vector2I((int)_atlasXSpin.Value, (int)_atlasYSpin.Value),
};
}
private void OnGenerate()
{
if (_selectedLayer == null) return;
var settings = BuildSettings();
var grid = CaGenerator.Generate(settings);
TilemapPainter.Paint(_selectedLayer, grid, settings, Plugin.UndoRedo);
SaveSettings();
}
private void OnSmooth()
{
if (_selectedLayer == null) return;
TilemapPainter.Smooth(_selectedLayer, BuildSettings(), Plugin.UndoRedo);
}
private void OnClear()
{
if (_selectedLayer == null) return;
TilemapPainter.Clear(_selectedLayer, BuildSettings(), Plugin.UndoRedo);
}
private void SaveSettings()
{
var es = EditorInterface.Singleton.GetEditorSettings();
es.SetSetting("ca_generator/mode", (int)_mode);
es.SetSetting("ca_generator/width", (int)_widthSpin.Value);
es.SetSetting("ca_generator/height", (int)_heightSpin.Value);
es.SetSetting("ca_generator/offset_x", (int)_offsetXSpin.Value);
es.SetSetting("ca_generator/offset_y", (int)_offsetYSpin.Value);
es.SetSetting("ca_generator/fill_density", _densitySlider.Value);
es.SetSetting("ca_generator/smoothing_passes", (int)_smoothPassSlider.Value);
es.SetSetting("ca_generator/border_walls", _borderCheck.ButtonPressed);
es.SetSetting("ca_generator/seed", (int)_seedSpin.Value);
es.SetSetting("ca_generator/source_id", (int)_sourceIdSpin.Value);
es.SetSetting("ca_generator/atlas_x", (int)_atlasXSpin.Value);
es.SetSetting("ca_generator/atlas_y", (int)_atlasYSpin.Value);
}
private void LoadSettings()
{
var es = EditorInterface.Singleton.GetEditorSettings();
if (es == null) return;
SetMode((CaMode)(es.HasSetting("ca_generator/mode") ? es.GetSetting("ca_generator/mode").AsInt32() : 0));
_widthSpin.Value = es.HasSetting("ca_generator/width") ? es.GetSetting("ca_generator/width").AsDouble() : 40.0;
_heightSpin.Value = es.HasSetting("ca_generator/height") ? es.GetSetting("ca_generator/height").AsDouble() : 22.0;
_offsetXSpin.Value = es.HasSetting("ca_generator/offset_x") ? es.GetSetting("ca_generator/offset_x").AsDouble() : 0.0;
_offsetYSpin.Value = es.HasSetting("ca_generator/offset_y") ? es.GetSetting("ca_generator/offset_y").AsDouble() : 0.0;
_densitySlider.Value = es.HasSetting("ca_generator/fill_density") ? es.GetSetting("ca_generator/fill_density").AsDouble() : 48.0;
_smoothPassSlider.Value = es.HasSetting("ca_generator/smoothing_passes") ? es.GetSetting("ca_generator/smoothing_passes").AsDouble() : 4.0;
_borderCheck.ButtonPressed = es.HasSetting("ca_generator/border_walls") ? es.GetSetting("ca_generator/border_walls").AsBool() : true;
_seedSpin.Value = es.HasSetting("ca_generator/seed") ? es.GetSetting("ca_generator/seed").AsDouble() : 12345.0;
_sourceIdSpin.Value = es.HasSetting("ca_generator/source_id") ? es.GetSetting("ca_generator/source_id").AsDouble() : 0.0;
_atlasXSpin.Value = es.HasSetting("ca_generator/atlas_x") ? es.GetSetting("ca_generator/atlas_x").AsDouble() : 0.0;
_atlasYSpin.Value = es.HasSetting("ca_generator/atlas_y") ? es.GetSetting("ca_generator/atlas_y").AsDouble() : 0.0;
_densityLabel.Text = $"{(int)_densitySlider.Value}%";
_smoothPassLabel.Text = $"{(int)_smoothPassSlider.Value}";
}
}
#endif

View File

@@ -0,0 +1 @@
uid://dnuvfv3rgyywd

View File

@@ -0,0 +1,37 @@
#if TOOLS
using Godot;
namespace Mr.BrickAdventures.Tools.CaLevelGenerator;
[Tool]
public partial class CaLevelGeneratorPlugin : EditorPlugin
{
private EditorDock _editorDock;
private CaLevelGeneratorDock _dock;
public override void _EnterTree()
{
_dock = new CaLevelGeneratorDock { Plugin = this };
_editorDock = new EditorDock { Title = "CA Level Generator" };
_editorDock.AddChild(_dock);
AddDock(_editorDock);
SceneChanged += OnSceneChanged;
}
public override void _ExitTree()
{
SceneChanged -= OnSceneChanged;
RemoveDock(_editorDock);
_editorDock.QueueFree();
_editorDock = null;
_dock = null;
}
private void OnSceneChanged(Node sceneRoot)
{
_dock?.OnSceneChanged(sceneRoot);
}
public EditorUndoRedoManager UndoRedo => GetUndoRedo();
}
#endif

View File

@@ -0,0 +1 @@
uid://cojnhxpcgepi2

View File

@@ -0,0 +1,100 @@
#if TOOLS
using Godot;
using Godot.Collections;
namespace Mr.BrickAdventures.Tools.CaLevelGenerator;
public static class TilemapPainter
{
public static void Paint(
TileMapLayer layer,
bool[,] grid,
CaGeneratorSettings settings,
EditorUndoRedoManager undoRedo)
{
var before = layer.TileMapData;
ApplyGrid(layer, grid, settings);
var after = layer.TileMapData;
RegisterUndo(layer, before, after, undoRedo, "CA Generate");
}
public static void Smooth(
TileMapLayer layer,
CaGeneratorSettings settings,
EditorUndoRedoManager undoRedo)
{
var grid = ReadGrid(layer, settings.Offset, new Vector2I(settings.Width, settings.Height));
grid = CaGenerator.Smooth(grid);
var before = layer.TileMapData;
ApplyGrid(layer, grid, settings);
var after = layer.TileMapData;
RegisterUndo(layer, before, after, undoRedo, "CA Smooth");
}
public static void Clear(
TileMapLayer layer,
CaGeneratorSettings settings,
EditorUndoRedoManager undoRedo)
{
var before = layer.TileMapData;
for (int x = 0; x < settings.Width; x++)
for (int y = 0; y < settings.Height; y++)
layer.EraseCell(settings.Offset + new Vector2I(x, y));
var after = layer.TileMapData;
RegisterUndo(layer, before, after, undoRedo, "CA Clear");
}
private static bool[,] ReadGrid(TileMapLayer layer, Vector2I offset, Vector2I size)
{
var grid = new bool[size.X, size.Y];
for (int x = 0; x < size.X; x++)
for (int y = 0; y < size.Y; y++)
grid[x, y] = layer.GetCellSourceId(offset + new Vector2I(x, y)) != -1;
return grid;
}
private static void ApplyGrid(TileMapLayer layer, bool[,] grid, CaGeneratorSettings settings)
{
int w = grid.GetLength(0), h = grid.GetLength(1);
for (int x = 0; x < w; x++)
for (int y = 0; y < h; y++)
layer.EraseCell(settings.Offset + new Vector2I(x, y));
if (settings.TerrainSet >= 0)
{
var solidCells = new Array<Vector2I>();
for (int x = 0; x < w; x++)
for (int y = 0; y < h; y++)
if (grid[x, y])
solidCells.Add(settings.Offset + new Vector2I(x, y));
if (solidCells.Count > 0)
layer.SetCellsTerrainConnect(solidCells, settings.TerrainSet, settings.Terrain);
}
else
{
for (int x = 0; x < w; x++)
for (int y = 0; y < h; y++)
if (grid[x, y])
layer.SetCell(
settings.Offset + new Vector2I(x, y),
settings.SourceId,
settings.AtlasCoords);
}
}
private static void RegisterUndo(
TileMapLayer layer,
byte[] before,
byte[] after,
EditorUndoRedoManager undoRedo,
string actionName)
{
undoRedo.CreateAction(actionName);
undoRedo.AddUndoProperty(layer, "tile_map_data", before);
undoRedo.AddDoProperty(layer, "tile_map_data", after);
undoRedo.CommitAction(false);
}
}
#endif

View File

@@ -0,0 +1 @@
uid://y55l4iw35tih

View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://test_ca_generator"]
[ext_resource type="Script" path="res://addons/ca_level_generator/CaGeneratorTestRunner.cs" id="1_catest"]
[node name="CaGeneratorTestRunner" type="Node"]
script = ExtResource("1_catest")

View File

@@ -0,0 +1,6 @@
[plugin]
name="CA Level Generator"
description="Cellular automata level blockout generator"
author="Gabriel Kaszewski"
version="1.0"
script="CaLevelGeneratorPlugin.cs"

View File

@@ -1,528 +0,0 @@
extends Node
var enabled := true
var enable_on_release_build := false: set = set_enable_on_release_build
var pause_enabled := false
signal console_opened
signal console_closed
signal console_unknown_command
class ConsoleCommand:
var function: Callable
var arguments: PackedStringArray
var required: int
var description: String
var hidden: bool
func _init(in_function: Callable, in_arguments: PackedStringArray, in_required: int = 0, in_description: String = ""):
function = in_function
arguments = in_arguments
required = in_required
description = in_description
var control := Control.new()
# If you want to customize the way the console looks, you can direcly modify
# the properties of the rich text and line edit here:
var rich_label := RichTextLabel.new()
var line_edit := LineEdit.new()
var console_commands := {}
var command_parameters := {}
var console_history := []
var console_history_index := 0
var was_paused_already := false
## Usage: Console.add_command("command_name", <function to call>, <number of arguments or array of argument names>, <required number of arguments>, "Help description")
func add_command(command_name: String, function: Callable, arguments = [], required: int = 0, description: String = "") -> void:
if (arguments is int):
# Legacy call using an argument number
var param_array: PackedStringArray
for i in range(arguments):
param_array.append("arg_" + str(i + 1))
console_commands[command_name] = ConsoleCommand.new(function, param_array, required, description)
elif (arguments is Array):
# New array argument system
var str_args: PackedStringArray
for argument in arguments:
str_args.append(str(argument))
console_commands[command_name] = ConsoleCommand.new(function, str_args, required, description)
## Adds a secret command that will not show up in the help or auto-complete.
func add_hidden_command(command_name: String, function: Callable, arguments = [], required: int = 0) -> void:
add_command(command_name, function, arguments, required)
console_commands[command_name].hidden = true
## Removes a command from the console. This should be called on a script's _exit_tree()
## if you have console commands for things that are unloaded before the project closes.
func remove_command(command_name: String) -> void:
console_commands.erase(command_name)
command_parameters.erase(command_name)
## Useful if you have a list of possible parameters (ex: level names).
func add_command_autocomplete_list(command_name: String, param_list: PackedStringArray):
command_parameters[command_name] = param_list
func _enter_tree() -> void:
var console_history_file := FileAccess.open("user://console_history.txt", FileAccess.READ)
if (console_history_file):
while (!console_history_file.eof_reached()):
var line := console_history_file.get_line()
if (line.length()):
add_input_history(line)
var canvas_layer := CanvasLayer.new()
canvas_layer.layer = 3
add_child(canvas_layer)
control.anchor_bottom = 1.0
control.anchor_right = 1.0
canvas_layer.add_child(control)
var style := StyleBoxFlat.new()
style.bg_color = Color("000000d7")
rich_label.selection_enabled = true
rich_label.context_menu_enabled = true
rich_label.bbcode_enabled = false
rich_label.scroll_following = true
rich_label.anchor_right = 1.0
rich_label.anchor_bottom = 0.5
rich_label.add_theme_stylebox_override("normal", style)
control.add_child(rich_label)
rich_label.append_text("Development console.\n")
line_edit.anchor_top = 0.5
line_edit.anchor_right = 1.0
line_edit.anchor_bottom = 0.5
line_edit.placeholder_text = "Enter \"help\" for instructions"
control.add_child(line_edit)
line_edit.text_submitted.connect(on_text_entered)
line_edit.text_changed.connect(on_line_edit_text_changed)
control.visible = false
process_mode = PROCESS_MODE_ALWAYS
func _exit_tree() -> void:
var console_history_file := FileAccess.open("user://console_history.txt", FileAccess.WRITE)
if (console_history_file):
var write_index := 0
var start_write_index := console_history.size() - 100 # Max lines to write
for line in console_history:
if (write_index >= start_write_index):
console_history_file.store_line(line)
write_index += 1
func _ready() -> void:
add_command("quit", quit, 0, 0, "Quits the game.")
add_command("exit", quit, 0, 0, "Quits the game.")
add_command("clear", clear, 0, 0, "Clears the text on the console.")
add_command("delete_history", delete_history, 0, 0, "Deletes the history of previously entered commands.")
add_command("help", help, 0, 0, "Displays instructions on how to use the console.")
add_command("commands_list", commands_list, 0, 0, "Lists all commands and their descriptions.")
add_command("commands", commands, 0, 0, "Lists commands with no descriptions.")
add_command("calc", calculate, ["mathematical expression to evaluate"], 0, "Evaluates the math passed in for quick arithmetic.")
add_command("echo", print_line, ["string"], 1, "Prints given string to the console.")
add_command("echo_warning", print_warning, ["string"], 1, "Prints given string as warning to the console.")
add_command("echo_info", print_info, ["string"], 1, "Prints given string as info to the console.")
add_command("echo_error", print_error, ["string"], 1, "Prints given string as an error to the console.")
add_command("pause", pause, 0, 0, "Pauses node processing.")
add_command("unpause", unpause, 0, 0, "Unpauses node processing.")
add_command("exec", exec, 1, 1, "Execute a script.")
func _input(event: InputEvent) -> void:
if (event is InputEventKey):
if (event.get_physical_keycode_with_modifiers() == KEY_QUOTELEFT): # ~ key.
if (event.pressed):
toggle_console()
get_tree().get_root().set_input_as_handled()
elif (event.physical_keycode == KEY_QUOTELEFT and event.is_command_or_control_pressed()): # Toggles console size or opens big console.
if (event.pressed):
if (control.visible):
toggle_size()
else:
toggle_console()
toggle_size()
get_tree().get_root().set_input_as_handled()
elif (event.get_physical_keycode_with_modifiers() == KEY_ESCAPE && control.visible): # Disable console on ESC
if (event.pressed):
toggle_console()
get_tree().get_root().set_input_as_handled()
if (control.visible and event.pressed):
if (event.get_physical_keycode_with_modifiers() == KEY_UP):
get_tree().get_root().set_input_as_handled()
if (console_history_index > 0):
console_history_index -= 1
if (console_history_index >= 0):
line_edit.text = console_history[console_history_index]
line_edit.caret_column = line_edit.text.length()
reset_autocomplete()
if (event.get_physical_keycode_with_modifiers() == KEY_DOWN):
get_tree().get_root().set_input_as_handled()
if (console_history_index < console_history.size()):
console_history_index += 1
if (console_history_index < console_history.size()):
line_edit.text = console_history[console_history_index]
line_edit.caret_column = line_edit.text.length()
reset_autocomplete()
else:
line_edit.text = ""
reset_autocomplete()
if (event.get_physical_keycode_with_modifiers() == KEY_PAGEUP):
var scroll := rich_label.get_v_scroll_bar()
var tween := create_tween()
tween.tween_property(scroll, "value", scroll.value - (scroll.page - scroll.page * 0.1), 0.1)
get_tree().get_root().set_input_as_handled()
if (event.get_physical_keycode_with_modifiers() == KEY_PAGEDOWN):
var scroll := rich_label.get_v_scroll_bar()
var tween := create_tween()
tween.tween_property(scroll, "value", scroll.value + (scroll.page - scroll.page * 0.1), 0.1)
get_tree().get_root().set_input_as_handled()
if (event.get_physical_keycode_with_modifiers() == KEY_TAB):
autocomplete()
get_tree().get_root().set_input_as_handled()
var suggestions := []
var current_suggest := 0
var suggesting := false
func autocomplete() -> void:
if (suggesting):
for i in range(suggestions.size()):
if (current_suggest == i):
line_edit.text = str(suggestions[i])
line_edit.caret_column = line_edit.text.length()
if (current_suggest == suggestions.size() - 1):
current_suggest = 0
else:
current_suggest += 1
return
else:
suggesting = true
if (" " in line_edit.text): # We're searching for a parameter to autocomplete
var split_text := parse_line_input(line_edit.text)
if (split_text.size() > 1):
var command := split_text[0]
var param_input := split_text[1]
if (command_parameters.has(command)):
for param in command_parameters[command]:
if (param_input in param):
suggestions.append(str(command, " ", param))
else:
var sorted_commands := []
for command in console_commands:
if (!console_commands[command].hidden):
sorted_commands.append(str(command))
sorted_commands.sort()
sorted_commands.reverse()
var prev_index := 0
for command in sorted_commands:
if (!line_edit.text || command.contains(line_edit.text)):
var index: int = command.find(line_edit.text)
if (index <= prev_index):
suggestions.push_front(command)
else:
suggestions.push_back(command)
prev_index = index
autocomplete()
func reset_autocomplete() -> void:
suggestions.clear()
current_suggest = 0
suggesting = false
func toggle_size() -> void:
if (control.anchor_bottom == 1.0):
control.anchor_bottom = 1.9
else:
control.anchor_bottom = 1.0
func disable():
enabled = false
toggle_console() # Ensure hidden if opened
func enable():
enabled = true
func toggle_console() -> void:
if (enabled):
control.visible = !control.visible
else:
control.visible = false
if (control.visible):
was_paused_already = get_tree().paused
get_tree().paused = was_paused_already || pause_enabled
line_edit.grab_focus()
console_opened.emit()
else:
control.anchor_bottom = 1.0
scroll_to_bottom()
reset_autocomplete()
if (pause_enabled && !was_paused_already):
get_tree().paused = false
console_closed.emit()
func is_visible():
return control.visible
func scroll_to_bottom() -> void:
var scroll: ScrollBar = rich_label.get_v_scroll_bar()
scroll.value = scroll.max_value - scroll.page
func print_error(text: Variant, print_godot := false) -> void:
if not text is String:
text = str(text)
print_line("[color=light_coral] ERROR:[/color] %s" % text, print_godot)
func print_info(text: Variant, print_godot := false) -> void:
if not text is String:
text = str(text)
print_line("[color=light_blue] INFO:[/color] %s" % text, print_godot)
func print_warning(text: Variant, print_godot := false) -> void:
if not text is String:
text = str(text)
print_line("[color=gold] WARNING:[/color] %s" % text, print_godot)
func print_line(text: Variant, print_godot := false) -> void:
if not text is String:
text = str(text)
if (!rich_label): # Tried to print something before the console was loaded.
call_deferred("print_line", text)
else:
rich_label.append_text(text)
rich_label.append_text("\n")
if (print_godot):
print(text)
func parse_line_input(text: String) -> PackedStringArray:
var out_array: PackedStringArray
var first_char := true
var in_quotes := false
var escaped := false
var token: String
for c in text:
if (c == '\\'):
escaped = true
continue
elif (escaped):
if (c == 'n'):
c = '\n'
elif (c == 't'):
c = '\t'
elif (c == 'r'):
c = '\r'
elif (c == 'a'):
c = '\a'
elif (c == 'b'):
c = '\b'
elif (c == 'f'):
c = '\f'
escaped = false
elif (c == '\"'):
in_quotes = !in_quotes
continue
elif (c == ' ' || c == '\t'):
if (!in_quotes):
out_array.push_back(token)
token = ""
continue
token += c
out_array.push_back(token)
return out_array
func on_text_entered(new_text: String) -> void:
scroll_to_bottom()
reset_autocomplete()
line_edit.clear()
if (line_edit.has_method(&"edit")):
line_edit.call_deferred(&"edit")
if not new_text.strip_edges().is_empty():
add_input_history(new_text)
print_line("[i]> " + new_text + "[/i]")
var text_split := parse_line_input(new_text)
var text_command := text_split[0]
if console_commands.has(text_command):
var arguments := text_split.slice(1)
var console_command: ConsoleCommand = console_commands[text_command]
# calc is a especial command that needs special treatment
if (text_command.match("calc")):
var expression := ""
for word in arguments:
expression += word
console_command.function.callv([expression])
return
if (arguments.size() < console_command.required):
print_error("Too few arguments! Required < %d >" % console_command.required)
return
elif (arguments.size() > console_command.arguments.size()):
arguments.resize(console_command.arguments.size())
# Functions fail to call if passed the incorrect number of arguments, so fill out with blank strings.
while (arguments.size() < console_command.arguments.size()):
arguments.append("")
console_command.function.callv(arguments)
else:
console_unknown_command.emit(text_command)
print_error("Command not found.")
func on_line_edit_text_changed(new_text: String) -> void:
reset_autocomplete()
func quit() -> void:
get_tree().quit()
func clear() -> void:
rich_label.clear()
func delete_history() -> void:
console_history.clear()
console_history_index = 0
DirAccess.remove_absolute("user://console_history.txt")
func help() -> void:
rich_label.append_text(" Built in commands:
[color=light_green]calc[/color]: Calculates a given expresion
[color=light_green]clear[/color]: Clears the registry view
[color=light_green]commands[/color]: Shows a reduced list of all the currently registered commands
[color=light_green]commands_list[/color]: Shows a detailed list of all the currently registered commands
[color=light_green]delete_history[/color]: Deletes the commands history
[color=light_green]echo[/color]: Prints a given string to the console
[color=light_green]echo_error[/color]: Prints a given string as an error to the console
[color=light_green]echo_info[/color]: Prints a given string as info to the console
[color=light_green]echo_warning[/color]: Prints a given string as warning to the console
[color=light_green]pause[/color]: Pauses node processing
[color=light_green]unpause[/color]: Unpauses node processing
[color=light_green]quit[/color]: Quits the game
Controls:
[color=light_blue]Up[/color] and [color=light_blue]Down[/color] arrow keys to navigate commands history
[color=light_blue]PageUp[/color] and [color=light_blue]PageDown[/color] to scroll registry
[[color=light_blue]Ctrl[/color] + [color=light_blue]~[/color]] to change console size between half screen and full screen
[color=light_blue]~[/color] or [color=light_blue]Esc[/color] key to close the console
[color=light_blue]Tab[/color] key to autocomplete, [color=light_blue]Tab[/color] again to cycle between matching suggestions\n\n")
func calculate(command: String) -> void:
var expression := Expression.new()
var error = expression.parse(command)
if error:
print_error("%s" % expression.get_error_text())
return
var result = expression.execute()
if not expression.has_execute_failed():
print_line(str(result))
else:
print_error("%s" % expression.get_error_text())
func commands() -> void:
var commands := []
for command in console_commands:
if (!console_commands[command].hidden):
commands.append(str(command))
commands.sort()
rich_label.append_text(" ")
rich_label.append_text(str(commands) + "\n\n")
func commands_list() -> void:
var commands := []
for command in console_commands:
if (!console_commands[command].hidden):
commands.append(str(command))
commands.sort()
for command in commands:
var arguments_string := ""
var description: String = console_commands[command].description
for i in range(console_commands[command].arguments.size()):
if i < console_commands[command].required:
arguments_string += " [color=cornflower_blue]<" + console_commands[command].arguments[i] + ">[/color]"
else:
arguments_string += " <" + console_commands[command].arguments[i] + ">"
rich_label.append_text(" [color=light_green]%s[/color][color=gray]%s[/color]: %s\n" % [command, arguments_string, description])
rich_label.append_text("\n")
func add_input_history(text: String) -> void:
if (!console_history.size() || text != console_history.back()): # Don't add consecutive duplicates
console_history.append(text)
console_history_index = console_history.size()
func set_enable_on_release_build(enable: bool):
enable_on_release_build = enable
if (!enable_on_release_build):
if (!OS.is_debug_build()):
disable()
func pause() -> void:
get_tree().paused = true
func unpause() -> void:
get_tree().paused = false
func exec(filename: String) -> void:
var path := "user://%s.txt" % [filename]
var script := FileAccess.open(path, FileAccess.READ)
if (script):
while (!script.eof_reached()):
on_text_entered(script.get_line())
else:
print_error("File %s not found." % [path])

View File

@@ -1 +0,0 @@
uid://ouiu5xh1cs8n

View File

@@ -1,11 +0,0 @@
@tool
extends EditorPlugin
func _enter_tree():
print("Console plugin activated.")
add_autoload_singleton("Console", "res://addons/console/console.gd")
func _exit_tree():
remove_autoload_singleton("Console")

View File

@@ -1 +0,0 @@
uid://cv2joe2dgkub1

View File

@@ -1,7 +0,0 @@
[plugin]
name="Developer Console"
description="Developer console. Press ~ to activate it in game and execute commands."
author="jitspoe"
version="1.3.1"
script="console_plugin.gd"

View File

@@ -0,0 +1,576 @@
using Godot;
using Godot.Collections;
using System;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
#nullable enable
namespace DialogueManagerRuntime
{
public enum TranslationSource
{
None,
Guess,
CSV,
PO
}
public partial class DialogueManager : RefCounted
{
public delegate void DialogueStartedEventHandler(Resource dialogueResource);
public delegate void PassedTitleEventHandler(string title);
public delegate void GotDialogueEventHandler(DialogueLine dialogueLine);
public delegate void MutatedEventHandler(Dictionary mutation);
public delegate void DialogueEndedEventHandler(Resource dialogueResource);
public static DialogueStartedEventHandler? DialogueStarted;
public static PassedTitleEventHandler? PassedTitle;
public static GotDialogueEventHandler? GotDialogue;
public static MutatedEventHandler? Mutated;
public static DialogueEndedEventHandler? DialogueEnded;
[Signal] public delegate void ResolvedEventHandler(Variant value);
private static GodotObject? instance;
public static GodotObject Instance
{
get
{
if (instance == null)
{
instance = Engine.GetSingleton("DialogueManager");
instance.Connect("bridge_dialogue_started", Callable.From((Resource dialogueResource) => DialogueStarted?.Invoke(dialogueResource)));
}
return instance;
}
}
public static Godot.Collections.Array GameStates
{
get => (Godot.Collections.Array)Instance.Get("game_states");
set => Instance.Set("game_states", value);
}
public static bool IncludeSingletons
{
get => (bool)Instance.Get("include_singletons");
set => Instance.Set("include_singletons", value);
}
public static bool IncludeClasses
{
get => (bool)Instance.Get("include_classes");
set => Instance.Set("include_classes", value);
}
public static TranslationSource TranslationSource
{
get => (TranslationSource)(int)Instance.Get("translation_source");
set => Instance.Set("translation_source", (int)value);
}
public static Func<Node> GetCurrentScene
{
set => Instance.Set("get_current_scene", Callable.From(value));
}
public static void Prepare(GodotObject instance)
{
instance.Connect("passed_title", Callable.From((string title) => PassedTitle?.Invoke(title)));
instance.Connect("got_dialogue", Callable.From((RefCounted line) => GotDialogue?.Invoke(new DialogueLine(line))));
instance.Connect("mutated", Callable.From((Dictionary mutation) => Mutated?.Invoke(mutation)));
instance.Connect("dialogue_ended", Callable.From((Resource dialogueResource) => DialogueEnded?.Invoke(dialogueResource)));
}
public static async Task<GodotObject> GetSingleton()
{
if (instance != null) return instance;
var tree = Engine.GetMainLoop();
int x = 0;
// Try and find the singleton for a few seconds
while (!Engine.HasSingleton("DialogueManager") && x < 300)
{
await tree.ToSignal(tree, SceneTree.SignalName.ProcessFrame);
x++;
}
// If it times out something is wrong
if (x >= 300)
{
throw new Exception("The DialogueManager singleton is missing.");
}
instance = Engine.GetSingleton("DialogueManager");
return instance;
}
public static Resource CreateResourceFromText(string text)
{
return (Resource)Instance.Call("create_resource_from_text", text);
}
public static async Task<DialogueLine?> GetNextDialogueLine(Resource dialogueResource, string key = "", Array<Variant>? extraGameStates = null)
{
var instance = (Node)Instance.Call("_bridge_get_new_instance");
Prepare(instance);
instance.Call("_bridge_get_next_dialogue_line", dialogueResource, key, extraGameStates ?? new Array<Variant>());
var result = await instance.ToSignal(instance, "bridge_get_next_dialogue_line_completed");
instance.QueueFree();
if ((RefCounted)result[0] == null) return null;
return new DialogueLine((RefCounted)result[0]);
}
public static CanvasLayer ShowExampleDialogueBalloon(Resource dialogueResource, string key = "", Array<Variant>? extraGameStates = null)
{
return (CanvasLayer)Instance.Call("show_example_dialogue_balloon", dialogueResource, key, extraGameStates ?? new Array<Variant>());
}
public static Node ShowDialogueBalloonScene(string balloonScene, Resource dialogueResource, string key = "", Array<Variant>? extraGameStates = null)
{
return (Node)Instance.Call("show_dialogue_balloon_scene", balloonScene, dialogueResource, key, extraGameStates ?? new Array<Variant>());
}
public static Node ShowDialogueBalloonScene(PackedScene balloonScene, Resource dialogueResource, string key = "", Array<Variant>? extraGameStates = null)
{
return (Node)Instance.Call("show_dialogue_balloon_scene", balloonScene, dialogueResource, key, extraGameStates ?? new Array<Variant>());
}
public static Node ShowDialogueBalloonScene(Node balloonScene, Resource dialogueResource, string key = "", Array<Variant>? extraGameStates = null)
{
return (Node)Instance.Call("show_dialogue_balloon_scene", balloonScene, dialogueResource, key, extraGameStates ?? new Array<Variant>());
}
public static Node ShowDialogueBalloon(Resource dialogueResource, string key = "", Array<Variant>? extraGameStates = null)
{
return (Node)Instance.Call("show_dialogue_balloon", dialogueResource, key, extraGameStates ?? new Array<Variant>());
}
public static Array<string> StaticIdToLineIds(Resource dialogueResource, string staticId)
{
return (Array<string>)Instance.Call("static_id_to_line_ids", dialogueResource, staticId);
}
public static string StaticIdToLineId(Resource dialogueResource, string staticId)
{
return (string)Instance.Call("static_id_to_line_id", dialogueResource, staticId);
}
public static async void Mutate(Dictionary mutation, Array<Variant>? extraGameStates = null, bool isInlineMutation = false)
{
Instance.Call("_bridge_mutate", mutation, extraGameStates ?? new Array<Variant>(), isInlineMutation);
await Instance.ToSignal(Instance, "bridge_mutated");
}
public static Array<Dictionary> GetMembersForAutoload(Script script)
{
Array<Dictionary> members = new Array<Dictionary>();
string typeName = script.ResourcePath.GetFile().GetBaseName();
var matchingTypes = Assembly.GetExecutingAssembly().GetTypes().Where(t => t.Name == typeName);
foreach (var matchingType in matchingTypes)
{
var memberInfos = matchingType.GetMembers(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly);
foreach (var memberInfo in memberInfos)
{
string type;
switch (memberInfo.MemberType)
{
case MemberTypes.Field:
FieldInfo fieldInfo = memberInfo as FieldInfo;
if (fieldInfo.FieldType.ToString().Contains("EventHandler"))
{
type = "signal";
}
else if (fieldInfo.IsLiteral)
{
type = "constant";
}
else
{
type = "property";
}
break;
case MemberTypes.Method:
type = "method";
break;
default:
continue;
}
members.Add(new Dictionary() {
{ "name", memberInfo.Name },
{ "type", type }
});
}
}
return members;
}
public bool ThingHasConstant(GodotObject thing, string property)
{
var fieldInfos = thing.GetType().GetFields(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly);
foreach (var fieldInfo in fieldInfos)
{
if (fieldInfo.Name == property && fieldInfo.IsLiteral)
{
return true;
}
}
return false;
}
public Variant ResolveThingConstant(GodotObject thing, string property)
{
var fieldInfos = thing.GetType().GetFields(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly);
foreach (var fieldInfo in fieldInfos)
{
if (fieldInfo.Name == property && fieldInfo.IsLiteral)
{
try
{
Variant value = fieldInfo.GetValue(thing) switch
{
int v => Variant.From((long)v),
float v => Variant.From((double)v),
System.String v => Variant.From((string)v),
_ => Variant.From(fieldInfo.GetValue(thing))
};
return value;
}
catch (Exception)
{
throw new Exception($"Constant {property} of type ${fieldInfo.GetValue(thing).GetType()} is not supported by Variant.");
}
}
}
throw new Exception($"{property} is not a public constant on {thing}");
}
public bool ThingHasMethod(GodotObject thing, string method, Array<Variant> args)
{
var methodInfos = thing.GetType().GetMethods(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly);
foreach (var methodInfo in methodInfos)
{
if (methodInfo.Name == method && args.Count >= methodInfo.GetParameters().Where(p => !p.HasDefaultValue).Count())
{
return true;
}
}
return false;
}
public async void ResolveThingMethod(GodotObject thing, string method, Array<Variant> args)
{
MethodInfo? info = null;
var methodInfos = thing.GetType().GetMethods(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly);
foreach (var methodInfo in methodInfos)
{
if (methodInfo.Name == method && args.Count >= methodInfo.GetParameters().Where(p => !p.HasDefaultValue).Count())
{
info = methodInfo;
}
}
if (info == null) return;
#nullable disable
// Convert the method args to something reflection can handle
ParameterInfo[] argTypes = info.GetParameters();
object[] _args = new object[argTypes.Length];
for (int i = 0; i < argTypes.Length; i++)
{
// check if args is assignable from derived type
if (i < args.Count && args[i].Obj != null)
{
if (argTypes[i].ParameterType.IsAssignableFrom(args[i].Obj.GetType()))
{
_args[i] = args[i].Obj;
}
// fallback to assigning primitive types
else
{
_args[i] = Convert.ChangeType(args[i].Obj, argTypes[i].ParameterType);
}
}
else if (argTypes[i].DefaultValue != null)
{
_args[i] = argTypes[i].DefaultValue;
}
}
// Add a single frame wait in case the method returns before signals can listen
await ToSignal(Engine.GetMainLoop(), SceneTree.SignalName.ProcessFrame);
// invoke method and handle the result based on return type
object result = info.Invoke(thing, _args);
if (result is Task taskResult)
{
await taskResult;
try
{
Variant value = (Variant)taskResult.GetType().GetProperty("Result").GetValue(taskResult);
EmitSignal(SignalName.Resolved, value);
}
catch (Exception)
{
EmitSignal(SignalName.Resolved);
}
}
else
{
EmitSignal(SignalName.Resolved, (Variant)result);
}
}
#nullable enable
}
public partial class DialogueLine : RefCounted
{
private string id = "";
public string Id
{
get => id;
set => id = value;
}
private string type = "dialogue";
public string Type
{
get => type;
set => type = value;
}
private string next_id = "";
public string NextId
{
get => next_id;
set => next_id = value;
}
private string character = "";
public string Character
{
get => character;
set => character = value;
}
private string text = "";
public string Text
{
get => text;
set => text = value;
}
private string translation_key = "";
public string TranslationKey
{
get => translation_key;
set => translation_key = value;
}
private Array<DialogueResponse> responses = new Array<DialogueResponse>();
public Array<DialogueResponse> Responses
{
get => responses;
}
private string? time = null;
public string? Time
{
get => time;
}
private Dictionary pauses = new Dictionary();
public Dictionary Pauses
{
get => pauses;
}
private Dictionary speeds = new Dictionary();
public Dictionary Speeds
{
get => speeds;
}
private Array<Godot.Collections.Array> inline_mutations = new Array<Godot.Collections.Array>();
public Array<Godot.Collections.Array> InlineMutations
{
get => inline_mutations;
}
private Array<DialogueLine> concurrent_lines = new Array<DialogueLine>();
public Array<DialogueLine> ConcurrentLines
{
get => concurrent_lines;
}
private Array<Variant> extra_game_states = new Array<Variant>();
public Array<Variant> ExtraGameStates
{
get => extra_game_states;
}
private Array<string> tags = new Array<string>();
public Array<string> Tags
{
get => tags;
}
public DialogueLine(RefCounted data)
{
id = (string)data.Get("id");
type = (string)data.Get("type");
next_id = (string)data.Get("next_id");
character = (string)data.Get("character");
text = (string)data.Get("text");
translation_key = (string)data.Get("translation_key");
pauses = (Dictionary)data.Get("pauses");
speeds = (Dictionary)data.Get("speeds");
inline_mutations = (Array<Godot.Collections.Array>)data.Get("inline_mutations");
time = (string)data.Get("time");
tags = (Array<string>)data.Get("tags");
foreach (var concurrent_line_data in (Array<RefCounted>)data.Get("concurrent_lines"))
{
concurrent_lines.Add(new DialogueLine(concurrent_line_data));
}
foreach (var response in (Array<RefCounted>)data.Get("responses"))
{
responses.Add(new DialogueResponse(response));
}
}
public string GetTagValue(string tagName)
{
string wrapped = $"{tagName}=";
foreach (var tag in tags)
{
if (tag.StartsWith(wrapped))
{
return tag.Substring(wrapped.Length);
}
}
return "";
}
public override string ToString()
{
switch (type)
{
case "dialogue":
return $"<DialogueLine character=\"{character}\" text=\"{text}\">";
case "mutation":
return "<DialogueLine mutation>";
default:
return "";
}
}
}
public partial class DialogueResponse : RefCounted
{
private string next_id = "";
public string NextId
{
get => next_id;
set => next_id = value;
}
private bool is_allowed = true;
public bool IsAllowed
{
get => is_allowed;
set => is_allowed = value;
}
private string condition_as_text = "";
public string ConditionAsText
{
get => condition_as_text;
set => condition_as_text = value;
}
private string text = "";
public string Text
{
get => text;
set => text = value;
}
private string translation_key = "";
public string TranslationKey
{
get => translation_key;
set => translation_key = value;
}
private Array<string> tags = new Array<string>();
public Array<string> Tags
{
get => tags;
}
public DialogueResponse(RefCounted data)
{
next_id = (string)data.Get("next_id");
is_allowed = (bool)data.Get("is_allowed");
text = (string)data.Get("text");
translation_key = (string)data.Get("translation_key");
tags = (Array<string>)data.Get("tags");
}
public string GetTagValue(string tagName)
{
string wrapped = $"{tagName}=";
foreach (var tag in tags)
{
if (tag.StartsWith(wrapped))
{
return tag.Substring(wrapped.Length);
}
}
return "";
}
public override string ToString()
{
return $"<DialogueResponse text=\"{text}\"";
}
}
}

View File

@@ -0,0 +1 @@
uid://c4c5lsrwy3opj

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022-present Nathan Hoad and Dialogue Manager contributors.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cnm67htuohhlo"
path="res://.godot/imported/banner.png-7e9e6a304eef850602c8d5afb80df9c3.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/dialogue_manager/assets/banner.png"
dest_files=["res://.godot/imported/banner.png-7e9e6a304eef850602c8d5afb80df9c3.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d3lr2uas6ax8v"
path="res://.godot/imported/icon.svg-17eb5d3e2a3cfbe59852220758c5b7bd.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/dialogue_manager/assets/icon.svg"
dest_files=["res://.godot/imported/icon.svg-17eb5d3e2a3cfbe59852220758c5b7bd.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="16"
height="16"
viewBox="0 0 4.2333333 4.2333335"
version="1.1"
id="svg291"
inkscape:version="1.3 (0e150ed6c4, 2023-07-21)"
sodipodi:docname="responses_menu.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview293"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:document-units="px"
showgrid="false"
width="1920px"
units="px"
borderlayer="true"
inkscape:showpageshadow="false"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="45.254834"
inkscape:cx="7.8334173"
inkscape:cy="6.5959804"
inkscape:window-width="2560"
inkscape:window-height="1377"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs288" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
id="rect181"
style="fill:#e0e0e0;fill-opacity:1;stroke:none;stroke-width:1.77487;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke markers fill"
d="M 1.5875 0.26458334 L 1.5875 0.79375001 L 4.2333334 0.79375001 L 4.2333334 0.26458334 L 1.5875 0.26458334 z M 0 0.83147381 L 0 2.4189738 L 1.3229167 1.6252238 L 0 0.83147381 z M 1.5875 1.3229167 L 1.5875 1.8520834 L 4.2333334 1.8520834 L 4.2333334 1.3229167 L 1.5875 1.3229167 z M 1.5875 2.38125 L 1.5875 2.9104167 L 4.2333334 2.9104167 L 4.2333334 2.38125 L 1.5875 2.38125 z M 1.5875 3.4395834 L 1.5875 3.9687501 L 4.2333334 3.9687501 L 4.2333334 3.4395834 L 1.5875 3.4395834 z "
fill="#E0E0E0" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://drjfciwitjm83"
path="res://.godot/imported/responses_menu.svg-87cf63ca685d53616205049572f4eb8f.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/dialogue_manager/assets/responses_menu.svg"
dest_files=["res://.godot/imported/responses_menu.svg-87cf63ca685d53616205049572f4eb8f.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d3baj6rygkb3f"
path="res://.godot/imported/update.svg-f1628866ed4eb2e13e3b81f75443687e.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/dialogue_manager/assets/update.svg"
dest_files=["res://.godot/imported/update.svg-f1628866ed4eb2e13e3b81f75443687e.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
uid://dsgpnyqg6cprg

View File

@@ -0,0 +1,161 @@
## A compiled line of dialogue.
class_name DMCompiledLine extends RefCounted
## The ID of the line
var id: String
## The translation key (or static line ID).
var translation_key: String = ""
## The type of line.
var type: String = ""
## The character name.
var character: String = ""
## Any interpolation expressions for the character name.
var character_replacements: Array[Dictionary] = []
## The text of the line.
var text: String = ""
## Any interpolation expressions for the text.
var text_replacements: Array[Dictionary] = []
## Any response siblings associated with this line.
var responses: PackedStringArray = []
## Any randomise or case siblings for this line.
var siblings: Array[Dictionary] = []
## Any lines said simultaneously.
var concurrent_lines: PackedStringArray = []
## Any tags on this line.
var tags: PackedStringArray = []
## The condition or mutation expression for this line.
var expression: Dictionary = {}
## The express as the raw text that was given.
var expression_text: String = ""
## The next sequential line to go to after this line.
var next_id: String = ""
## The next line to go to after this line if it is unknown and compile time.
var next_id_expression: Array[Dictionary] = []
## Whether this jump line should return after the jump target sequence has ended.
var is_snippet: bool = false
## The ID of the next sibling line.
var next_sibling_id: String = ""
## The ID after this line if it belongs to a block (eg. conditions).
var next_id_after: String = ""
## Any doc comments attached to this line.
var notes: String = ""
#region Hooks
func _init(initial_id: String, initial_type: String) -> void:
id = initial_id
type = initial_type
func _to_string() -> String:
var s: Array = [
"[%s]" % [type],
"%s:" % [character] if character != "" else null,
text if text != "" else null,
expression if expression.size() > 0 else null,
"[%s]" % [",".join(tags)] if tags.size() > 0 else null,
str(siblings) if siblings.size() > 0 else null,
str(responses) if responses.size() > 0 else null,
"=> END" if "end" in next_id else "=> %s" % [next_id],
"(~> %s)" % [next_sibling_id] if next_sibling_id != "" else null,
"(==> %s)" % [next_id_after] if next_id_after != "" else null,
].filter(func(item): return item != null)
return " ".join(s)
#endregion
#region Helpers
## Express this line as a [Dictionary] that can be stored in a resource.
func to_data() -> Dictionary:
var d: Dictionary = {
id = id,
type = type,
next_id = next_id
}
if next_id_expression.size() > 0:
d.next_id_expression = next_id_expression
match type:
DMConstants.TYPE_CONDITION:
d.condition = expression
if not next_sibling_id.is_empty():
d.next_sibling_id = next_sibling_id
d.next_id_after = next_id_after
DMConstants.TYPE_WHILE:
d.condition = expression
d.next_id_after = next_id_after
DMConstants.TYPE_MATCH:
d.condition = expression
d.next_id_after = next_id_after
d.cases = siblings
DMConstants.TYPE_MUTATION:
d.mutation = expression
DMConstants.TYPE_GOTO:
d.is_snippet = is_snippet
d.next_id_after = next_id_after
if not siblings.is_empty():
d.siblings = siblings
DMConstants.TYPE_RANDOM:
d.siblings = siblings
DMConstants.TYPE_RESPONSE:
d.text = text
if not responses.is_empty():
d.responses = responses
if translation_key != text:
d.translation_key = translation_key
if not expression.is_empty():
d.condition = expression
if not character.is_empty():
d.character = character
if not character_replacements.is_empty():
d.character_replacements = character_replacements
if not text_replacements.is_empty():
d.text_replacements = text_replacements
if not tags.is_empty():
d.tags = tags
if not notes.is_empty():
d.notes = notes
if not expression_text.is_empty():
d.condition_as_text = expression_text
DMConstants.TYPE_DIALOGUE:
d.text = text
if translation_key != text:
d.translation_key = translation_key
if not character.is_empty():
d.character = character
if not character_replacements.is_empty():
d.character_replacements = character_replacements
if not text_replacements.is_empty():
d.text_replacements = text_replacements
if not tags.is_empty():
d.tags = tags
if not notes.is_empty():
d.notes = notes
if not siblings.is_empty():
d.siblings = siblings
if not concurrent_lines.is_empty():
d.concurrent_lines = concurrent_lines
return d
#endregion

View File

@@ -0,0 +1 @@
uid://dg8j5hudp4210

View File

@@ -0,0 +1,51 @@
## A compiler of Dialogue Manager dialogue.
class_name DMCompiler extends RefCounted
## Compile a dialogue script.
static func compile_string(text: String, path: String) -> DMCompilerResult:
var compilation: DMCompilation = DMCompilation.new()
compilation.compile(text, path)
var result: DMCompilerResult = DMCompilerResult.new()
result.imported_paths = compilation.imported_paths
result.using_states = compilation.using_states
result.character_names = compilation.character_names
result.titles = compilation.titles
result.first_title = compilation.first_title
result.errors = compilation.errors
result.lines = compilation.data
result.raw_text = text
return result
## Get the line type of a string. The returned string will match one of the [code]TYPE_[/code] constants of [DMConstants].
static func get_line_type(text: String) -> String:
var compilation: DMCompilation = DMCompilation.new()
return compilation.get_line_type(text)
## Get the static line ID (eg. [code][ID:SOMETHING][/code]) of some text.
static func get_static_line_id(text: String) -> String:
var compilation: DMCompilation = DMCompilation.new()
return compilation.extract_static_line_id(text)
## Get the translatable part of a line.
static func extract_translatable_string(text: String) -> String:
var compilation: DMCompilation = DMCompilation.new()
var tree_line = DMTreeLine.new("")
tree_line.text = text
var line: DMCompiledLine = DMCompiledLine.new("", compilation.get_line_type(text))
compilation.parse_character_and_dialogue(tree_line, line, [tree_line], 0, null)
return line.text
## Get the known titles in a dialogue script.
static func get_titles_in_text(text: String, path: String) -> Dictionary:
var compilation: DMCompilation = DMCompilation.new()
compilation.build_line_tree(compilation.inject_imported_files(text, path))
return compilation.titles

View File

@@ -0,0 +1 @@
uid://chtfdmr0cqtp4

View File

@@ -0,0 +1,50 @@
## A collection of [RegEx] for use by the [DMCompiler].
class_name DMCompilerRegEx extends RefCounted
var IMPORT_REGEX: RegEx = RegEx.create_from_string("import \"(?<path>[^\"]+)\" as (?<prefix>[a-zA-Z_\\p{Emoji_Presentation}\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}][a-zA-Z_0-9\\p{Emoji_Presentation}\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}]+)")
var USING_REGEX: RegEx = RegEx.create_from_string("^using (?<state>.*)$")
var INDENT_REGEX: RegEx = RegEx.create_from_string("^\\t+")
var VALID_TITLE_REGEX: RegEx = RegEx.create_from_string("^[a-zA-Z_0-9\\p{Emoji_Presentation}\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}][a-zA-Z_0-9\\p{Emoji_Presentation}\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}]*$")
var BEGINS_WITH_NUMBER_REGEX: RegEx = RegEx.create_from_string("^\\d")
var CONDITION_REGEX: RegEx = RegEx.create_from_string("(if|elif|while|else if|match|when) (?<expression>.*)\\:?")
var WRAPPED_CONDITION_REGEX: RegEx = RegEx.create_from_string("\\[if (?<expression>.*)\\]")
var MUTATION_REGEX: RegEx = RegEx.create_from_string("(?<keyword>do|do!|set) (?<expression>.*)")
var STATIC_LINE_ID_REGEX: RegEx = RegEx.create_from_string("\\[ID:(?<id>.*?)\\]")
var WEIGHTED_RANDOM_SIBLINGS_REGEX: RegEx = RegEx.create_from_string("^\\%(?<weight>[\\d.]+)?( \\[if (?<condition>.+?)\\])? ")
var GOTO_REGEX: RegEx = RegEx.create_from_string("=><? (?<goto>.*)")
var INLINE_RANDOM_REGEX: RegEx = RegEx.create_from_string("\\[\\[(?<options>.*?)\\]\\]")
var INLINE_CONDITIONALS_REGEX: RegEx = RegEx.create_from_string("\\[if (?<condition>.+?)\\](?<body>.*?)\\[\\/if\\]")
var TAGS_REGEX: RegEx = RegEx.create_from_string("\\[#(?<tags>.*?)\\]")
var REPLACEMENTS_REGEX: RegEx = RegEx.create_from_string("{{(.*?)}}")
var ALPHA_NUMERIC: RegEx = RegEx.create_from_string("[^a-zA-Z0-9\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}]+")
var TOKEN_DEFINITIONS: Dictionary = {
DMConstants.TOKEN_FUNCTION: RegEx.create_from_string("^[a-zA-Z_\\p{Emoji_Presentation}\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}][a-zA-Z_0-9\\p{Emoji_Presentation}\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}]*\\("),
DMConstants.TOKEN_DICTIONARY_REFERENCE: RegEx.create_from_string("^[a-zA-Z_\\p{Emoji_Presentation}\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}][a-zA-Z_0-9\\p{Emoji_Presentation}\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}]*\\["),
DMConstants.TOKEN_PARENS_OPEN: RegEx.create_from_string("^\\("),
DMConstants.TOKEN_PARENS_CLOSE: RegEx.create_from_string("^\\)"),
DMConstants.TOKEN_BRACKET_OPEN: RegEx.create_from_string("^\\["),
DMConstants.TOKEN_BRACKET_CLOSE: RegEx.create_from_string("^\\]"),
DMConstants.TOKEN_BRACE_OPEN: RegEx.create_from_string("^\\{"),
DMConstants.TOKEN_BRACE_CLOSE: RegEx.create_from_string("^\\}"),
DMConstants.TOKEN_COLON: RegEx.create_from_string("^:"),
DMConstants.TOKEN_COMPARISON: RegEx.create_from_string("^(==|<=|>=|<|>|!=|in )"),
DMConstants.TOKEN_ASSIGNMENT: RegEx.create_from_string("^(\\+=|\\-=|\\*=|/=|=)"),
DMConstants.TOKEN_NUMBER: RegEx.create_from_string("^\\-?\\d+(\\.\\d+)?"),
DMConstants.TOKEN_OPERATOR: RegEx.create_from_string("^(\\+|\\-|\\*|/|%)"),
DMConstants.TOKEN_COMMA: RegEx.create_from_string("^,"),
DMConstants.TOKEN_NULL_COALESCE: RegEx.create_from_string("^\\?\\."),
DMConstants.TOKEN_DOT: RegEx.create_from_string("^\\."),
DMConstants.TOKEN_STRING: RegEx.create_from_string("^&?(\".*?\"|\'.*?\')"),
DMConstants.TOKEN_NOT: RegEx.create_from_string("^(not( |$)|!)"),
DMConstants.TOKEN_AND_OR: RegEx.create_from_string("^(and|or|&&|\\|\\|)( |$)"),
DMConstants.TOKEN_VARIABLE: RegEx.create_from_string("^[a-zA-Z_\\p{Emoji_Presentation}\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}][a-zA-Z_0-9\\p{Emoji_Presentation}\\p{Han}\\p{Katakana}\\p{Hiragana}\\p{Cyrillic}]*"),
DMConstants.TOKEN_COMMENT: RegEx.create_from_string("^#.*"),
DMConstants.TOKEN_CONDITION: RegEx.create_from_string("^(if|elif|else)"),
DMConstants.TOKEN_BOOL: RegEx.create_from_string("^(true|false)")
}

View File

@@ -0,0 +1 @@
uid://d3tvcrnicjibp

View File

@@ -0,0 +1,27 @@
## The result of using the [DMCompiler] to compile some dialogue.
class_name DMCompilerResult extends RefCounted
## Any paths that were imported into the compiled dialogue file.
var imported_paths: PackedStringArray = []
## Any "using" directives.
var using_states: PackedStringArray = []
## All titles in the file and the line they point to.
var titles: Dictionary = {}
## The first title in the file.
var first_title: String = ""
## All character names.
var character_names: PackedStringArray = []
## Any compilation errors.
var errors: Array[Dictionary] = []
## A map of all compiled lines.
var lines: Dictionary = {}
## The raw dialogue text.
var raw_text: String = ""

View File

@@ -0,0 +1 @@
uid://dmk74tknimqvg

View File

@@ -0,0 +1,529 @@
## A class for parsing a condition/mutation expression for use with the [DMCompiler].
class_name DMExpressionParser extends RefCounted
var include_comments: bool = false
# Reference to the common [RegEx] that the parser needs.
var regex: DMCompilerRegEx = DMCompilerRegEx.new()
## Break a string down into an expression.
func tokenise(text: String, line_type: String, index: int) -> Array:
var tokens: Array[Dictionary] = []
var limit: int = 0
while text.strip_edges() != "" and limit < 1000:
limit += 1
var found = _find_match(text)
if found.size() > 0:
tokens.append({
index = index,
type = found.type,
value = found.value
})
index += found.value.length()
text = found.remaining_text
elif text.begins_with(" "):
index += 1
text = text.substr(1)
else:
return _build_token_tree_error([], DMConstants.ERR_INVALID_EXPRESSION, index)
return _build_token_tree(tokens, line_type, "")[0]
## Extract any expressions from some text
func extract_replacements(text: String, index: int) -> Array[Dictionary]:
var founds: Array[RegExMatch] = regex.REPLACEMENTS_REGEX.search_all(text)
if founds == null or founds.size() == 0:
return []
var replacements: Array[Dictionary] = []
for found in founds:
var replacement: Dictionary = {}
var value_in_text: String = found.strings[0].substr(0, found.strings[0].length() - 2).substr(2)
# If there are closing curlie hard-up against the end of a {{...}} block then check for further
# curlies just outside of the block.
var text_suffix: String = text.substr(found.get_end(0))
var expression_suffix: String = ""
while text_suffix.begins_with("}"):
expression_suffix += "}"
text_suffix = text_suffix.substr(1)
value_in_text += expression_suffix
var expression: Array = tokenise(value_in_text, DMConstants.TYPE_DIALOGUE, index + found.get_start(1))
if expression.size() == 0:
replacement = {
index = index + found.get_start(1),
error = DMConstants.ERR_INCOMPLETE_EXPRESSION
}
elif expression[0].type == DMConstants.TYPE_ERROR:
replacement = {
index = expression[0].i,
error = expression[0].value
}
else:
replacement = {
value_in_text = "{{%s}}" % value_in_text,
expression = expression
}
replacements.append(replacement)
return replacements
#region Helpers
# Create a token that represents an error.
func _build_token_tree_error(tree: Array, error: int, index: int) -> Array:
tree.insert(0, {
type = DMConstants.TOKEN_ERROR,
value = error,
i = index
})
return tree
# Convert a list of tokens into an abstract syntax tree.
func _build_token_tree(tokens: Array[Dictionary], line_type: String, expected_close_token: String) -> Array:
var tree: Array[Dictionary] = []
var limit = 0
while tokens.size() > 0 and limit < 1000:
limit += 1
var token = tokens.pop_front()
var error = _check_next_token(token, tokens, line_type, expected_close_token)
if error != OK:
var error_token: Dictionary = tokens[1] if tokens.size() > 1 else token
return [_build_token_tree_error(tree, error, error_token.index), tokens]
match token.type:
DMConstants.TOKEN_COMMENT:
if include_comments:
tree.append({
type = DMConstants.TOKEN_COMMENT,
value = token.value,
i = token.index
})
DMConstants.TOKEN_FUNCTION:
var sub_tree = _build_token_tree(tokens, line_type, DMConstants.TOKEN_PARENS_CLOSE)
if sub_tree[0].size() > 0 and sub_tree[0][0].type == DMConstants.TOKEN_ERROR:
return [_build_token_tree_error(tree, sub_tree[0][0].value, sub_tree[0][0].i), tokens]
tree.append({
type = DMConstants.TOKEN_FUNCTION,
# Consume the trailing "("
function = token.value.substr(0, token.value.length() - 1),
value = _tokens_to_list(sub_tree[0]),
i = token.index
})
tokens = sub_tree[1]
DMConstants.TOKEN_DICTIONARY_REFERENCE:
var sub_tree = _build_token_tree(tokens, line_type, DMConstants.TOKEN_BRACKET_CLOSE)
if sub_tree[0].size() > 0 and sub_tree[0][0].type == DMConstants.TOKEN_ERROR:
return [_build_token_tree_error(tree, sub_tree[0][0].value, sub_tree[0][0].i), tokens]
var args = _tokens_to_list(sub_tree[0])
if args.size() != 1:
return [_build_token_tree_error(tree, DMConstants.ERR_INVALID_INDEX, token.index), tokens]
tree.append({
type = DMConstants.TOKEN_DICTIONARY_REFERENCE,
# Consume the trailing "["
variable = token.value.substr(0, token.value.length() - 1),
value = args[0],
i = token.index
})
tokens = sub_tree[1]
DMConstants.TOKEN_BRACE_OPEN:
var sub_tree = _build_token_tree(tokens, line_type, DMConstants.TOKEN_BRACE_CLOSE)
if sub_tree[0].size() > 0 and sub_tree[0][0].type == DMConstants.TOKEN_ERROR:
return [_build_token_tree_error(tree, sub_tree[0][0].value, sub_tree[0][0].i), tokens]
var t = sub_tree[0]
for i in range(0, t.size() - 2):
# Convert Lua style dictionaries to string keys
if t[i].type == DMConstants.TOKEN_VARIABLE and t[i+1].type == DMConstants.TOKEN_ASSIGNMENT:
t[i].type = DMConstants.TOKEN_STRING
t[i+1].type = DMConstants.TOKEN_COLON
t[i+1].erase("value")
tree.append({
type = DMConstants.TOKEN_DICTIONARY,
value = _tokens_to_dictionary(sub_tree[0]),
i = token.index
})
tokens = sub_tree[1]
DMConstants.TOKEN_BRACKET_OPEN:
var sub_tree = _build_token_tree(tokens, line_type, DMConstants.TOKEN_BRACKET_CLOSE)
if sub_tree[0].size() > 0 and sub_tree[0][0].type == DMConstants.TOKEN_ERROR:
return [_build_token_tree_error(tree, sub_tree[0][0].value, sub_tree[0][0].i), tokens]
var type = DMConstants.TOKEN_ARRAY
var value = _tokens_to_list(sub_tree[0])
# See if this is referencing a nested dictionary value
if tree.size() > 0:
var previous_token = tree[tree.size() - 1]
if previous_token.type in [DMConstants.TOKEN_DICTIONARY_REFERENCE, DMConstants.TOKEN_DICTIONARY_NESTED_REFERENCE]:
type = DMConstants.TOKEN_DICTIONARY_NESTED_REFERENCE
value = value[0]
tree.append({
type = type,
value = value,
i = token.index
})
tokens = sub_tree[1]
DMConstants.TOKEN_PARENS_OPEN:
var sub_tree = _build_token_tree(tokens, line_type, DMConstants.TOKEN_PARENS_CLOSE)
if sub_tree[0].size() > 0 and sub_tree[0][0].type == DMConstants.TOKEN_ERROR:
return [_build_token_tree_error(tree, sub_tree[0][0].value, sub_tree[0][0].i), tokens]
tree.append({
type = DMConstants.TOKEN_GROUP,
value = sub_tree[0],
i = token.index
})
tokens = sub_tree[1]
DMConstants.TOKEN_PARENS_CLOSE, \
DMConstants.TOKEN_BRACE_CLOSE, \
DMConstants.TOKEN_BRACKET_CLOSE:
if token.type != expected_close_token:
return [_build_token_tree_error(tree, DMConstants.ERR_UNEXPECTED_CLOSING_BRACKET, token.index), tokens]
tree.append({
type = token.type,
i = token.index
})
return [tree, tokens]
DMConstants.TOKEN_NOT:
# Double nots negate each other
if tokens.size() > 0 and tokens.front().type == DMConstants.TOKEN_NOT:
tokens.pop_front()
else:
tree.append({
type = token.type,
i = token.index
})
DMConstants.TOKEN_COMMA, \
DMConstants.TOKEN_COLON, \
DMConstants.TOKEN_DOT, \
DMConstants.TOKEN_NULL_COALESCE:
tree.append({
type = token.type,
i = token.index
})
DMConstants.TOKEN_COMPARISON, \
DMConstants.TOKEN_ASSIGNMENT, \
DMConstants.TOKEN_OPERATOR, \
DMConstants.TOKEN_AND_OR, \
DMConstants.TOKEN_VARIABLE:
var value = token.value.strip_edges()
if value == "&&":
value = "and"
elif value == "||":
value = "or"
tree.append({
type = token.type,
value = value,
i = token.index
})
DMConstants.TOKEN_STRING:
if token.value.begins_with("&"):
tree.append({
type = token.type,
value = StringName(token.value.substr(2, token.value.length() - 3)),
i = token.index
})
else:
tree.append({
type = token.type,
value = token.value.substr(1, token.value.length() - 2),
i = token.index
})
DMConstants.TOKEN_CONDITION:
return [_build_token_tree_error(tree, DMConstants.ERR_UNEXPECTED_CONDITION, token.index), token]
DMConstants.TOKEN_BOOL:
tree.append({
type = token.type,
value = token.value.to_lower() == "true",
i = token.index
})
DMConstants.TOKEN_NUMBER:
var value = token.value.to_float() if "." in token.value else token.value.to_int()
# If previous token is a number and this one is a negative number then
# inject a minus operator token in between them.
if tree.size() > 0 and token.value.begins_with("-") and tree[tree.size() - 1].type == DMConstants.TOKEN_NUMBER:
tree.append(({
type = DMConstants.TOKEN_OPERATOR,
value = "-",
i = token.index
}))
tree.append({
type = token.type,
value = -1 * value,
i = token.index
})
else:
tree.append({
type = token.type,
value = value,
i = token.index
})
if expected_close_token != "":
var index: int = tokens[0].i if tokens.size() > 0 else 0
return [_build_token_tree_error(tree, DMConstants.ERR_MISSING_CLOSING_BRACKET, index), tokens]
return [tree, tokens]
# Check the next token to see if it is valid to follow this one.
func _check_next_token(token: Dictionary, next_tokens: Array[Dictionary], line_type: String, expected_close_token: String) -> Error:
var next_token: Dictionary = { type = null }
if next_tokens.size() > 0:
next_token = next_tokens.front()
# Guard for assigning in a condition. If the assignment token isn't inside a Lua dictionary
# then it's an unexpected assignment in a condition line.
if token.type == DMConstants.TOKEN_ASSIGNMENT and line_type == DMConstants.TYPE_CONDITION and not next_tokens.any(func(t): return t.type == expected_close_token):
return DMConstants.ERR_UNEXPECTED_ASSIGNMENT
# Special case for a negative number after this one
if token.type == DMConstants.TOKEN_NUMBER and next_token.type == DMConstants.TOKEN_NUMBER and next_token.value.begins_with("-"):
return OK
var expected_token_types = []
var unexpected_token_types = []
match token.type:
DMConstants.TOKEN_FUNCTION, \
DMConstants.TOKEN_PARENS_OPEN:
unexpected_token_types = [
null,
DMConstants.TOKEN_COMMA,
DMConstants.TOKEN_COLON,
DMConstants.TOKEN_COMPARISON,
DMConstants.TOKEN_ASSIGNMENT,
DMConstants.TOKEN_OPERATOR,
DMConstants.TOKEN_AND_OR,
DMConstants.TOKEN_DOT
]
DMConstants.TOKEN_BRACKET_CLOSE:
unexpected_token_types = [
DMConstants.TOKEN_NOT,
DMConstants.TOKEN_BOOL,
DMConstants.TOKEN_STRING,
DMConstants.TOKEN_NUMBER,
DMConstants.TOKEN_VARIABLE
]
DMConstants.TOKEN_BRACE_OPEN:
expected_token_types = [
DMConstants.TOKEN_STRING,
DMConstants.TOKEN_VARIABLE,
DMConstants.TOKEN_NUMBER,
DMConstants.TOKEN_BRACE_CLOSE
]
DMConstants.TOKEN_PARENS_CLOSE, \
DMConstants.TOKEN_BRACE_CLOSE:
unexpected_token_types = [
DMConstants.TOKEN_NOT,
DMConstants.TOKEN_ASSIGNMENT,
DMConstants.TOKEN_BOOL,
DMConstants.TOKEN_STRING,
DMConstants.TOKEN_NUMBER,
DMConstants.TOKEN_VARIABLE
]
DMConstants.TOKEN_COMPARISON, \
DMConstants.TOKEN_OPERATOR, \
DMConstants.TOKEN_DOT, \
DMConstants.TOKEN_NULL_COALESCE, \
DMConstants.TOKEN_NOT, \
DMConstants.TOKEN_AND_OR, \
DMConstants.TOKEN_DICTIONARY_REFERENCE:
unexpected_token_types = [
null,
DMConstants.TOKEN_COMMA,
DMConstants.TOKEN_COLON,
DMConstants.TOKEN_COMPARISON,
DMConstants.TOKEN_ASSIGNMENT,
DMConstants.TOKEN_OPERATOR,
DMConstants.TOKEN_AND_OR,
DMConstants.TOKEN_PARENS_CLOSE,
DMConstants.TOKEN_BRACE_CLOSE,
DMConstants.TOKEN_BRACKET_CLOSE,
DMConstants.TOKEN_DOT
]
DMConstants.TOKEN_COMMA:
unexpected_token_types = [
null,
DMConstants.TOKEN_COMMA,
DMConstants.TOKEN_COLON,
DMConstants.TOKEN_ASSIGNMENT,
DMConstants.TOKEN_OPERATOR,
DMConstants.TOKEN_AND_OR,
DMConstants.TOKEN_PARENS_CLOSE,
DMConstants.TOKEN_BRACE_CLOSE,
DMConstants.TOKEN_BRACKET_CLOSE,
DMConstants.TOKEN_DOT
]
DMConstants.TOKEN_COLON:
unexpected_token_types = [
DMConstants.TOKEN_COMMA,
DMConstants.TOKEN_COLON,
DMConstants.TOKEN_COMPARISON,
DMConstants.TOKEN_ASSIGNMENT,
DMConstants.TOKEN_OPERATOR,
DMConstants.TOKEN_AND_OR,
DMConstants.TOKEN_PARENS_CLOSE,
DMConstants.TOKEN_BRACE_CLOSE,
DMConstants.TOKEN_BRACKET_CLOSE,
DMConstants.TOKEN_DOT
]
DMConstants.TOKEN_BOOL, \
DMConstants.TOKEN_STRING, \
DMConstants.TOKEN_NUMBER:
unexpected_token_types = [
DMConstants.TOKEN_NOT,
DMConstants.TOKEN_ASSIGNMENT,
DMConstants.TOKEN_BOOL,
DMConstants.TOKEN_STRING,
DMConstants.TOKEN_NUMBER,
DMConstants.TOKEN_VARIABLE,
DMConstants.TOKEN_FUNCTION,
DMConstants.TOKEN_PARENS_OPEN,
DMConstants.TOKEN_BRACE_OPEN,
DMConstants.TOKEN_BRACKET_OPEN
]
DMConstants.TOKEN_VARIABLE:
unexpected_token_types = [
DMConstants.TOKEN_NOT,
DMConstants.TOKEN_BOOL,
DMConstants.TOKEN_STRING,
DMConstants.TOKEN_NUMBER,
DMConstants.TOKEN_VARIABLE,
DMConstants.TOKEN_FUNCTION,
DMConstants.TOKEN_PARENS_OPEN,
DMConstants.TOKEN_BRACE_OPEN,
DMConstants.TOKEN_BRACKET_OPEN
]
if (expected_token_types.size() > 0 and not next_token.type in expected_token_types) \
or (unexpected_token_types.size() > 0 and next_token.type in unexpected_token_types):
match next_token.type:
null:
return DMConstants.ERR_UNEXPECTED_END_OF_EXPRESSION
DMConstants.TOKEN_FUNCTION:
return DMConstants.ERR_UNEXPECTED_FUNCTION
DMConstants.TOKEN_PARENS_OPEN, \
DMConstants.TOKEN_PARENS_CLOSE:
return DMConstants.ERR_UNEXPECTED_BRACKET
DMConstants.TOKEN_COMPARISON, \
DMConstants.TOKEN_ASSIGNMENT, \
DMConstants.TOKEN_OPERATOR, \
DMConstants.TOKEN_NOT, \
DMConstants.TOKEN_AND_OR:
return DMConstants.ERR_UNEXPECTED_OPERATOR
DMConstants.TOKEN_COMMA:
return DMConstants.ERR_UNEXPECTED_COMMA
DMConstants.TOKEN_COLON:
return DMConstants.ERR_UNEXPECTED_COLON
DMConstants.TOKEN_DOT:
return DMConstants.ERR_UNEXPECTED_DOT
DMConstants.TOKEN_BOOL:
return DMConstants.ERR_UNEXPECTED_BOOLEAN
DMConstants.TOKEN_STRING:
return DMConstants.ERR_UNEXPECTED_STRING
DMConstants.TOKEN_NUMBER:
return DMConstants.ERR_UNEXPECTED_NUMBER
DMConstants.TOKEN_VARIABLE:
return DMConstants.ERR_UNEXPECTED_VARIABLE
return DMConstants.ERR_INVALID_EXPRESSION
return OK
# Convert a series of comma separated tokens to an [Array].
func _tokens_to_list(tokens: Array[Dictionary]) -> Array[Array]:
var list: Array[Array] = []
var current_item: Array[Dictionary] = []
for token in tokens:
if token.type == DMConstants.TOKEN_COMMA:
list.append(current_item)
current_item = []
else:
current_item.append(token)
if current_item.size() > 0:
list.append(current_item)
return list
# Convert a series of key/value tokens into a [Dictionary]
func _tokens_to_dictionary(tokens: Array[Dictionary]) -> Dictionary:
var dictionary = {}
for i in range(0, tokens.size()):
if tokens[i].type == DMConstants.TOKEN_COLON:
if tokens.size() == i + 2:
dictionary[tokens[i - 1]] = tokens[i + 1]
else:
dictionary[tokens[i - 1]] = { type = DMConstants.TOKEN_GROUP, value = tokens.slice(i + 1), i = tokens[0].i }
return dictionary
# Work out what the next token is from a string.
func _find_match(input: String) -> Dictionary:
for key in regex.TOKEN_DEFINITIONS.keys():
var regex = regex.TOKEN_DEFINITIONS.get(key)
var found = regex.search(input)
if found:
return {
type = key,
remaining_text = input.substr(found.strings[0].length()),
value = found.strings[0]
}
return {}
#endregion

View File

@@ -0,0 +1 @@
uid://dbi4hbar8ubwu

View File

@@ -0,0 +1,68 @@
## Data associated with a dialogue jump/goto line.
class_name DMResolvedGotoData extends RefCounted
## The title that was specified
var title: String = ""
## The target line's ID
var next_id: String = ""
## An expression to determine the target line at runtime.
var expression: Array[Dictionary] = []
## The given line text with the jump syntax removed.
var text_without_goto: String = ""
## Whether this is a jump-and-return style jump.
var is_snippet: bool = false
## A parse error if there was one.
var error: int
## The index in the string where
var index: int = 0
# An instance of the compiler [RegEx] list.
var regex: DMCompilerRegEx = DMCompilerRegEx.new()
func _init(text: String, titles: Dictionary) -> void:
if not "=> " in text and not "=>< " in text: return
if "=> " in text:
text_without_goto = text.substr(0, text.find("=> ")).strip_edges()
elif "=>< " in text:
is_snippet = true
text_without_goto = text.substr(0, text.find("=>< ")).strip_edges()
var found: RegExMatch = regex.GOTO_REGEX.search(text)
if found == null:
return
title = found.strings[found.names.goto].strip_edges()
index = found.get_start(0)
if title == "":
error = DMConstants.ERR_UNKNOWN_TITLE
return
# "=> END!" means end the conversation, ignoring any "=><" chains.
if title == "END!":
next_id = DMConstants.ID_END_CONVERSATION
# "=> END" means end the current title (and go back to the previous one if there is one
# in the stack)
elif title == "END":
next_id = DMConstants.ID_END
elif titles.has(title):
next_id = titles.get(title)
elif title.begins_with("{{"):
var expression_parser: DMExpressionParser = DMExpressionParser.new()
var title_expression: Array[Dictionary] = expression_parser.extract_replacements(title, 0)
if title_expression[0].has("error"):
error = title_expression[0].error
else:
expression = title_expression[0].expression
else:
next_id = title
error = DMConstants.ERR_UNKNOWN_TITLE
func _to_string() -> String:
return "%s =>%s %s (%s)" % [text_without_goto, "<" if is_snippet else "", title, next_id]

View File

@@ -0,0 +1 @@
uid://llhl5pt47eoq

View File

@@ -0,0 +1,167 @@
## Any data associated with inline dialogue BBCodes.
class_name DMResolvedLineData extends RefCounted
## The line's text
var text: String = ""
## A map of pauses against where they are found in the text.
var pauses: Dictionary = {}
## A map of speed changes against where they are found in the text.
var speeds: Dictionary = {}
## A list of any mutations to run and where they are found in the text.
var mutations: Array[Array] = []
## A duration reference for the line. Represented as "auto" or a stringified number.
var time: String = ""
func _init(line: String) -> void:
text = line
pauses = {}
speeds = {}
mutations = []
time = ""
var bbcodes: Array = []
# Remove any escaped brackets (ie. "\[")
var escaped_open_brackets: PackedInt32Array = []
var escaped_close_brackets: PackedInt32Array = []
for i in range(0, text.length() - 1):
if text.substr(i, 2) == "\\[":
text = text.substr(0, i) + "!" + text.substr(i + 2)
escaped_open_brackets.append(i)
elif text.substr(i, 2) == "\\]":
text = text.substr(0, i) + "!" + text.substr(i + 2)
escaped_close_brackets.append(i)
# Extract all of the BB codes so that we know the actual text (we could do this easier with
# a RichTextLabel but then we'd need to await idle_frame which is annoying)
var bbcode_positions = find_bbcode_positions_in_string(text)
var accumulaive_length_offset = 0
for position in bbcode_positions:
# Ignore our own markers
if position.code in ["wait", "speed", "/speed", "do", "do!", "set", "next", "if", "else", "/if"]:
continue
bbcodes.append({
bbcode = position.bbcode,
start = position.start,
offset_start = position.start - accumulaive_length_offset
})
accumulaive_length_offset += position.bbcode.length()
for bb in bbcodes:
text = text.substr(0, bb.offset_start) + text.substr(bb.offset_start + bb.bbcode.length())
# Now find any dialogue markers
var next_bbcode_position = find_bbcode_positions_in_string(text, false)
var limit = 0
while next_bbcode_position.size() > 0 and limit < 1000:
limit += 1
var bbcode = next_bbcode_position[0]
var index = bbcode.start
var code = bbcode.code
var raw_args = bbcode.raw_args
var args = {}
if code in ["do", "do!", "set"]:
var compilation: DMCompilation = DMCompilation.new()
args["value"] = compilation.extract_mutation("%s %s" % [code, raw_args])
else:
# Could be something like:
# "=1.0"
# " rate=20 level=10"
if raw_args and raw_args[0] == "=":
raw_args = "value" + raw_args
for pair in raw_args.strip_edges().split(" "):
if "=" in pair:
var bits = pair.split("=")
args[bits[0]] = bits[1]
match code:
"wait":
if pauses.has(index):
pauses[index] += args.get("value").to_float()
else:
pauses[index] = args.get("value").to_float()
"speed":
speeds[index] = args.get("value").to_float()
"/speed":
speeds[index] = 1.0
"do", "do!", "set":
mutations.append([index, args.get("value")])
"next":
time = args.get("value") if args.has("value") else "0"
# Find any BB codes that are after this index and remove the length from their start
var length = bbcode.bbcode.length()
for bb in bbcodes:
if bb.offset_start > bbcode.start:
bb.offset_start -= length
bb.start -= length
# Find any escaped brackets after this that need moving
for i in range(0, escaped_open_brackets.size()):
if escaped_open_brackets[i] > bbcode.start:
escaped_open_brackets[i] -= length
for i in range(0, escaped_close_brackets.size()):
if escaped_close_brackets[i] > bbcode.start:
escaped_close_brackets[i] -= length
text = text.substr(0, index) + text.substr(index + length)
next_bbcode_position = find_bbcode_positions_in_string(text, false)
# Put the BB Codes back in
for bb in bbcodes:
text = text.insert(bb.start, bb.bbcode)
# Put the escaped brackets back in
for index in escaped_open_brackets:
text = text.left(index) + "[" + text.right(text.length() - index - 1)
for index in escaped_close_brackets:
text = text.left(index) + "]" + text.right(text.length() - index - 1)
func find_bbcode_positions_in_string(string: String, find_all: bool = true, include_conditions: bool = false) -> Array[Dictionary]:
if not "[" in string: return []
var positions: Array[Dictionary] = []
var open_brace_count: int = 0
var start: int = 0
var bbcode: String = ""
var code: String = ""
var is_finished_code: bool = false
for i in range(0, string.length()):
if string[i] == "[":
if open_brace_count == 0:
start = i
bbcode = ""
code = ""
is_finished_code = false
open_brace_count += 1
else:
if not is_finished_code and (string[i].to_upper() != string[i] or string[i] == "/" or string[i] == "!"):
code += string[i]
else:
is_finished_code = true
if open_brace_count > 0:
bbcode += string[i]
if string[i] == "]":
open_brace_count -= 1
if open_brace_count == 0 and (include_conditions or not code in ["if", "else", "/if"]):
positions.append({
bbcode = bbcode,
code = code,
start = start,
end = i,
raw_args = bbcode.substr(code.length() + 1, bbcode.length() - code.length() - 2).strip_edges()
})
if not find_all:
return positions
return positions

View File

@@ -0,0 +1 @@
uid://0k6q8kukq0qa

View File

@@ -0,0 +1,26 @@
## Tag data associated with a line of dialogue.
class_name DMResolvedTagData extends RefCounted
## The list of tags.
var tags: PackedStringArray = []
## The line with any tag syntax removed.
var text_without_tags: String = ""
# An instance of the compiler [RegEx].
var regex: DMCompilerRegEx = DMCompilerRegEx.new()
func _init(text: String) -> void:
var resolved_tags: PackedStringArray = []
var tag_matches: Array[RegExMatch] = regex.TAGS_REGEX.search_all(text)
for tag_match in tag_matches:
text = text.replace(tag_match.get_string(), "")
var tags = tag_match.get_string().replace("[#", "").replace("]", "").replace(", ", ",").split(",")
for tag in tags:
tag = tag.replace("#", "")
if not tag in resolved_tags:
resolved_tags.append(tag)
tags = resolved_tags
text_without_tags = text

View File

@@ -0,0 +1 @@
uid://cqai3ikuilqfq

View File

@@ -0,0 +1,46 @@
## An intermediate representation of a dialogue line before it gets compiled.
class_name DMTreeLine extends RefCounted
## The line number where this dialogue was found (after imported files have had their content imported).
var line_number: int = 0
## The parent [DMTreeLine] of this line.
## This is stored as a Weak Reference so that this RefCounted can elegantly free itself.
## Without it being a Weak Reference, this can easily cause a cyclical reference that keeps this resource alive.
var parent: WeakRef
## The ID of this line.
var id: String
## The type of this line (as a [String] defined in [DMConstants].
var type: String = ""
## Is this line part of a randomised group?
var is_random: bool = false
## The indent count for this line.
var indent: int = 0
## The text of this line.
var text: String = ""
## The child [DMTreeLine]s of this line.
var children: Array[DMTreeLine] = []
## Any doc comments attached to this line.
var notes: String = ""
## Is this a dialogue line that is the child of another dialogue line?
var is_nested_dialogue: bool = false
func _init(initial_id: String) -> void:
id = initial_id
func _to_string() -> String:
var tabs = []
tabs.resize(indent)
tabs.fill("\t")
tabs = "".join(tabs)
return tabs.join([tabs + "{\n",
"\tid: %s\n" % [id],
"\ttype: %s\n" % [type],
"\tis_random: %s\n" % ["true" if is_random else "false"],
"\ttext: %s\n" % [text],
"\tnotes: %s\n" % [notes],
"\tchildren: []\n" if children.size() == 0 else "\tchildren: [\n" + ",\n".join(children.map(func(child): return str(child))) + "]\n",
"}"])

View File

@@ -0,0 +1 @@
uid://dsu4i84dpif14

View File

@@ -0,0 +1,610 @@
@tool
class_name DMCodeEdit extends CodeEdit
signal active_title_change(title: String)
signal error_clicked(line_number: int)
signal external_file_requested(path: String, title: String)
# A link back to the owner `MainView`
var main_view
# Theme overrides for syntax highlighting, etc
var theme_overrides: Dictionary:
set(value):
theme_overrides = value
syntax_highlighter = DMSyntaxHighlighter.new()
# General UI
add_theme_color_override("font_color", theme_overrides.text_color)
add_theme_color_override("background_color", theme_overrides.background_color)
add_theme_color_override("current_line_color", theme_overrides.current_line_color)
add_theme_font_override("font", get_theme_font("source", "EditorFonts"))
add_theme_font_size_override("font_size", theme_overrides.font_size * theme_overrides.scale)
font_size = round(theme_overrides.font_size)
get:
return theme_overrides
# Any parse errors
var errors: Array:
set(next_errors):
errors = next_errors
for i in range(0, get_line_count()):
var is_error: bool = false
for error in errors:
if error.line_number == i:
is_error = true
mark_line_as_error(i, is_error)
_on_code_edit_caret_changed()
get:
return errors
# The last selection (if there was one) so we can remember it for refocusing
var last_selected_text: String
var font_size: int:
set(value):
font_size = value
add_theme_font_size_override("font_size", font_size * theme_overrides.scale)
get:
return font_size
var WEIGHTED_RANDOM_PREFIX: RegEx = RegEx.create_from_string("^\\%[\\d.]+\\s")
var compiler_regex: DMCompilerRegEx = DMCompilerRegEx.new()
var _autoloads: Dictionary[String, String] = {}
var _autoload_member_cache: Dictionary[String, Dictionary] = {}
func _ready() -> void:
# Add error gutter
add_gutter(0)
set_gutter_type(0, TextEdit.GUTTER_TYPE_ICON)
# Add comment delimiter
if not has_comment_delimiter("#"):
add_comment_delimiter("#", "", true)
syntax_highlighter = DMSyntaxHighlighter.new()
# Keep track of any autoloads
ProjectSettings.settings_changed.connect(_on_project_settings_changed)
_on_project_settings_changed()
func _gui_input(event: InputEvent) -> void:
# Handle shortcuts that come from the editor
if event is InputEventKey and event.is_pressed():
var shortcut: String = Engine.get_meta("DialogueManagerPlugin").get_editor_shortcut(event)
match shortcut:
"toggle_comment":
toggle_comment()
get_viewport().set_input_as_handled()
"delete_line":
delete_current_line()
get_viewport().set_input_as_handled()
"move_up":
move_line(-1)
get_viewport().set_input_as_handled()
"move_down":
move_line(1)
get_viewport().set_input_as_handled()
"text_size_increase":
self.font_size += 1
get_viewport().set_input_as_handled()
"text_size_decrease":
self.font_size -= 1
get_viewport().set_input_as_handled()
"text_size_reset":
self.font_size = theme_overrides.font_size
get_viewport().set_input_as_handled()
elif event is InputEventMouse:
match event.as_text():
"Ctrl+Mouse Wheel Up", "Command+Mouse Wheel Up":
self.font_size += 1
get_viewport().set_input_as_handled()
"Ctrl+Mouse Wheel Down", "Command+Mouse Wheel Down":
self.font_size -= 1
get_viewport().set_input_as_handled()
func _can_drop_data(at_position: Vector2, data) -> bool:
if typeof(data) != TYPE_DICTIONARY: return false
if data.type != "files": return false
var files: PackedStringArray = Array(data.files)
return files.size() > 0
func _drop_data(at_position: Vector2, data) -> void:
var replace_regex: RegEx = RegEx.create_from_string("[^a-zA-Z_0-9]+")
var files: PackedStringArray = Array(data.files)
for file in files:
# Don't import the file into itself
if file == main_view.current_file_path: continue
if file.get_extension() == "dialogue":
var path = file.replace("res://", "").replace(".dialogue", "")
# Find the first non-import line in the file to add our import
var lines = text.split("\n")
for i in range(0, lines.size()):
if not lines[i].begins_with("import "):
insert_line_at(i, "import \"%s\" as %s\n" % [file, replace_regex.sub(path, "_", true)])
set_caret_line(i)
break
else:
var cursor: Vector2 = get_line_column_at_pos(at_position)
if cursor.x > -1 and cursor.y > -1:
set_cursor(cursor)
remove_secondary_carets()
insert_text("\"%s\"" % file, cursor.y, cursor.x)
grab_focus()
func _request_code_completion(force: bool) -> void:
var cursor: Vector2 = get_cursor()
var current_line: String = get_line(cursor.y)
# Match jumps
if ("=> " in current_line or "=>< " in current_line) and (cursor.x > current_line.find("=>")):
var prompt: String = current_line.split("=>")[1]
if prompt.begins_with("< "):
prompt = prompt.substr(2)
else:
prompt = prompt.substr(1)
if "=> " in current_line:
if matches_prompt(prompt, "end"):
add_code_completion_option(CodeEdit.KIND_CLASS, "END", "END".substr(prompt.length()), theme_overrides.text_color, get_theme_icon("Stop", "EditorIcons"))
if matches_prompt(prompt, "end!"):
add_code_completion_option(CodeEdit.KIND_CLASS, "END!", "END!".substr(prompt.length()), theme_overrides.text_color, get_theme_icon("Stop", "EditorIcons"))
# Get all titles, including those in imports
for title: String in DMCompiler.get_titles_in_text(text, main_view.current_file_path):
# Ignore any imported titles that aren't resolved to human readable.
if title.to_int() > 0:
continue
elif "/" in title:
var bits = title.split("/")
if matches_prompt(prompt, bits[0]) or matches_prompt(prompt, bits[1]):
add_code_completion_option(CodeEdit.KIND_CLASS, title, title.substr(prompt.length()), theme_overrides.text_color, get_theme_icon("CombineLines", "EditorIcons"))
elif matches_prompt(prompt, title):
add_code_completion_option(CodeEdit.KIND_CLASS, title, title.substr(prompt.length()), theme_overrides.text_color, get_theme_icon("ArrowRight", "EditorIcons"))
# Match character names
var name_so_far: String = WEIGHTED_RANDOM_PREFIX.sub(current_line.strip_edges(), "")
if name_so_far != "" and name_so_far[0].to_upper() == name_so_far[0]:
# Only show names starting with that character
var names: PackedStringArray = get_character_names(name_so_far)
if names.size() > 0:
for name in names:
add_code_completion_option(CodeEdit.KIND_CLASS, name + ": ", name.substr(name_so_far.length()) + ": ", theme_overrides.text_color, get_theme_icon("Sprite2D", "EditorIcons"))
# Match autoloads on mutation lines
for prefix in ["do ", "do! ", "set ", "if ", "elif ", "else if ", "match ", "when ", "using "]:
if (current_line.strip_edges().begins_with(prefix) and (cursor.x > current_line.find(prefix))):
var expression: String = current_line.substr(0, cursor.x).strip_edges().substr(3)
# Find the last couple of tokens
var possible_prompt: String = expression.reverse()
possible_prompt = possible_prompt.substr(0, possible_prompt.find(" "))
possible_prompt = possible_prompt.substr(0, possible_prompt.find("("))
possible_prompt = possible_prompt.reverse()
var segments: PackedStringArray = possible_prompt.split(".").slice(-2)
var auto_completes: Array[Dictionary] = []
# Autoloads and state shortcuts
if segments.size() == 1:
var prompt: String = segments[0]
for autoload in _autoloads.keys():
if matches_prompt(prompt, autoload):
auto_completes.append({
prompt = prompt,
text = autoload,
type = "script"
})
for autoload in get_state_shortcuts():
for member: Dictionary in get_members_for_autoload(autoload):
if matches_prompt(prompt, member.name):
auto_completes.append({
prompt = prompt,
text = member.name,
type = member.type
})
# Members of an autoload
elif segments[0] in _autoloads.keys() and not current_line.strip_edges().begins_with("using "):
var prompt: String = segments[1]
for member: Dictionary in get_members_for_autoload(segments[0]):
if matches_prompt(prompt, member.name):
auto_completes.append({
prompt = prompt,
text = member.name,
type = member.type
})
auto_completes.sort_custom(func(a, b): return a.text < b.text)
for auto_complete in auto_completes:
var icon: Texture2D
var text: String = auto_complete.text
match auto_complete.type:
"script":
icon = get_theme_icon("Script", "EditorIcons")
"property":
icon = get_theme_icon("MemberProperty", "EditorIcons")
"method":
icon = get_theme_icon("MemberMethod", "EditorIcons")
text += "()"
"signal":
icon = get_theme_icon("MemberSignal", "EditorIcons")
"constant":
icon = get_theme_icon("MemberConstant", "EditorIcons")
var insert: String = text.substr(auto_complete.prompt.length())
add_code_completion_option(CodeEdit.KIND_CLASS, text, insert, theme_overrides.text_color, icon)
update_code_completion_options(true)
if get_code_completion_options().size() == 0:
cancel_code_completion()
func _filter_code_completion_candidates(candidates: Array) -> Array:
# Not sure why but if this method isn't overridden then all completions are wrapped in quotes.
return candidates
func _confirm_code_completion(replace: bool) -> void:
var completion = get_code_completion_option(get_code_completion_selected_index())
begin_complex_operation()
# Delete any part of the text that we've already typed
if completion.insert_text.length() > 0:
for i in range(0, completion.display_text.length() - completion.insert_text.length()):
backspace()
# Insert the whole match
insert_text_at_caret(completion.display_text)
end_complex_operation()
if completion.display_text.ends_with("()"):
set_cursor(get_cursor() - Vector2.RIGHT)
# Close the autocomplete menu on the next tick
call_deferred("cancel_code_completion")
#region Helpers
# Get the current caret as a Vector2
func get_cursor() -> Vector2:
return Vector2(get_caret_column(), get_caret_line())
# Set the caret from a Vector2
func set_cursor(from_cursor: Vector2) -> void:
set_caret_line(from_cursor.y, false)
set_caret_column(from_cursor.x, false)
# Check if a prompt is the start of a string without actually being that string
func matches_prompt(prompt: String, matcher: String) -> bool:
return prompt.length() < matcher.length() and matcher.to_lower().begins_with(prompt.to_lower())
func get_state_shortcuts() -> PackedStringArray:
# Get any shortcuts defined in settings
var shortcuts: PackedStringArray = DMSettings.get_setting(DMSettings.STATE_AUTOLOAD_SHORTCUTS, [])
# Check for "using" clauses
for line: String in text.split("\n"):
var found: RegExMatch = compiler_regex.USING_REGEX.search(line)
if found:
shortcuts.append(found.strings[found.names.state])
# Check for any other script sources
for extra_script_source in DMSettings.get_setting(DMSettings.EXTRA_AUTO_COMPLETE_SCRIPT_SOURCES, []):
shortcuts.append(extra_script_source)
return shortcuts
func get_members_for_autoload(autoload_name: String) -> Array[Dictionary]:
# Debounce method list lookups
if _autoload_member_cache.has(autoload_name) and _autoload_member_cache.get(autoload_name).get("at") > Time.get_ticks_msec() - 5000:
return _autoload_member_cache.get(autoload_name).get("members")
if not _autoloads.has(autoload_name) and not autoload_name.begins_with("res://") and not autoload_name.begins_with("uid://"): return []
var autoload = load(_autoloads.get(autoload_name, autoload_name))
var script: Script = autoload if autoload is Script else autoload.get_script()
if not is_instance_valid(script): return []
var members: Array[Dictionary] = []
if script.resource_path.ends_with(".gd"):
for m: Dictionary in script.get_script_method_list():
if not m.name.begins_with("@"):
members.append({
name = m.name,
type = "method"
})
for m: Dictionary in script.get_script_property_list():
members.append({
name = m.name,
type = "property"
})
for m: Dictionary in script.get_script_signal_list():
members.append({
name = m.name,
type = "signal"
})
for c: String in script.get_script_constant_map():
members.append({
name = c,
type = "constant"
})
elif script.resource_path.ends_with(".cs"):
var dotnet = load(Engine.get_meta("DialogueManagerPlugin").get_plugin_path() + "/DialogueManager.cs").new()
for m: Dictionary in dotnet.GetMembersForAutoload(script):
members.append(m)
_autoload_member_cache[autoload_name] = {
at = Time.get_ticks_msec(),
members = members
}
return members
## Get a list of titles from the current text
func get_titles() -> PackedStringArray:
var titles = PackedStringArray([])
var lines = text.split("\n")
for line in lines:
if line.strip_edges().begins_with("~ "):
titles.append(line.strip_edges().substr(2))
return titles
## Work out what the next title above the current line is
func check_active_title() -> void:
var line_number = get_caret_line()
var lines = text.split("\n")
# Look at each line above this one to find the next title line
for i in range(line_number, -1, -1):
if lines[i].begins_with("~ "):
active_title_change.emit(lines[i].replace("~ ", ""))
return
active_title_change.emit("")
# Move the caret line to match a given title
func go_to_title(title: String) -> void:
var lines = text.split("\n")
for i in range(0, lines.size()):
if lines[i].strip_edges() == "~ " + title:
set_caret_line(i)
center_viewport_to_caret()
func get_character_names(beginning_with: String) -> PackedStringArray:
var names: PackedStringArray = []
var lines = text.split("\n")
for line in lines:
if ": " in line:
var name: String = WEIGHTED_RANDOM_PREFIX.sub(line.split(": ")[0].strip_edges(), "")
if not name in names and matches_prompt(beginning_with, name):
names.append(name)
return names
# Mark a line as an error or not
func mark_line_as_error(line_number: int, is_error: bool) -> void:
# Lines display counting from 1 but are actually indexed from 0
line_number -= 1
if line_number < 0: return
if is_error:
set_line_background_color(line_number, theme_overrides.error_line_color)
set_line_gutter_icon(line_number, 0, get_theme_icon("StatusError", "EditorIcons"))
else:
set_line_background_color(line_number, theme_overrides.background_color)
set_line_gutter_icon(line_number, 0, null)
# Insert or wrap some bbcode at the caret/selection
func insert_bbcode(open_tag: String, close_tag: String = "") -> void:
if close_tag == "":
insert_text_at_caret(open_tag)
grab_focus()
else:
var selected_text = get_selected_text()
insert_text_at_caret("%s%s%s" % [open_tag, selected_text, close_tag])
grab_focus()
set_caret_column(get_caret_column() - close_tag.length())
# Insert text at current caret position
# Move Caret down 1 line if not => END
func insert_text_at_cursor(text: String) -> void:
if text != "=> END":
insert_text_at_caret(text+"\n")
set_caret_line(get_caret_line()+1)
else:
insert_text_at_caret(text)
grab_focus()
# Toggle the selected lines as comments
func toggle_comment() -> void:
begin_complex_operation()
var comment_delimiter: String = delimiter_comments[0]
var is_first_line: bool = true
var will_comment: bool = true
var selections: Array = []
var line_offsets: Dictionary = {}
for caret_index in range(0, get_caret_count()):
var from_line: int = get_caret_line(caret_index)
var from_column: int = get_caret_column(caret_index)
var to_line: int = get_caret_line(caret_index)
var to_column: int = get_caret_column(caret_index)
if has_selection(caret_index):
from_line = get_selection_from_line(caret_index)
to_line = get_selection_to_line(caret_index)
from_column = get_selection_from_column(caret_index)
to_column = get_selection_to_column(caret_index)
selections.append({
from_line = from_line,
from_column = from_column,
to_line = to_line,
to_column = to_column
})
for line_number in range(from_line, to_line + 1):
if line_offsets.has(line_number): continue
var line_text: String = get_line(line_number)
# The first line determines if we are commenting or uncommentingg
if is_first_line:
is_first_line = false
will_comment = not line_text.strip_edges().begins_with(comment_delimiter)
# Only comment/uncomment if the current line needs to
if will_comment:
set_line(line_number, comment_delimiter + line_text)
line_offsets[line_number] = 1
elif line_text.begins_with(comment_delimiter):
set_line(line_number, line_text.substr(comment_delimiter.length()))
line_offsets[line_number] = -1
else:
line_offsets[line_number] = 0
for caret_index in range(0, get_caret_count()):
var selection: Dictionary = selections[caret_index]
select(
selection.from_line,
selection.from_column + line_offsets[selection.from_line],
selection.to_line,
selection.to_column + line_offsets[selection.to_line],
caret_index
)
set_caret_column(selection.from_column + line_offsets[selection.from_line], false, caret_index)
end_complex_operation()
text_set.emit()
text_changed.emit()
# Remove the current line
func delete_current_line() -> void:
var cursor = get_cursor()
if get_line_count() == 1:
select_all()
elif cursor.y == 0:
select(0, 0, 1, 0)
else:
select(cursor.y - 1, get_line_width(cursor.y - 1), cursor.y, get_line_width(cursor.y))
delete_selection()
text_changed.emit()
# Move the selected lines up or down
func move_line(offset: int) -> void:
offset = clamp(offset, -1, 1)
var starting_scroll := scroll_vertical
var cursor = get_cursor()
var reselect: bool = false
var from: int = cursor.y
var to: int = cursor.y
if has_selection():
reselect = true
from = get_selection_from_line()
to = get_selection_to_line()
var lines := text.split("\n")
# Prevent the lines from being out of bounds
if from + offset < 0 or to + offset >= lines.size(): return
var target_from_index = from - 1 if offset == -1 else to + 1
var target_to_index = to if offset == -1 else from
var line_to_move = lines[target_from_index]
lines.remove_at(target_from_index)
lines.insert(target_to_index, line_to_move)
text = "\n".join(lines)
cursor.y += offset
set_cursor(cursor)
from += offset
to += offset
if reselect:
select(from, 0, to, get_line_width(to))
text_changed.emit()
scroll_vertical = starting_scroll + offset
#endregion
#region Signals
func _on_project_settings_changed() -> void:
_autoloads = {}
var project = ConfigFile.new()
project.load("res://project.godot")
for autoload in project.get_section_keys("autoload"):
if autoload != "DialogueManager":
_autoloads[autoload] = project.get_value("autoload", autoload).substr(1)
func _on_code_edit_symbol_validate(symbol: String) -> void:
if symbol.begins_with("res://") and symbol.ends_with(".dialogue"):
set_symbol_lookup_word_as_valid(true)
return
for title in get_titles():
if symbol == title:
set_symbol_lookup_word_as_valid(true)
return
set_symbol_lookup_word_as_valid(false)
func _on_code_edit_symbol_lookup(symbol: String, line: int, column: int) -> void:
if symbol.begins_with("res://") and symbol.ends_with(".dialogue"):
external_file_requested.emit(symbol, "")
else:
go_to_title(symbol)
func _on_code_edit_text_changed() -> void:
request_code_completion(true)
func _on_code_edit_text_set() -> void:
queue_redraw()
func _on_code_edit_caret_changed() -> void:
check_active_title()
last_selected_text = get_selected_text()
func _on_code_edit_gutter_clicked(line: int, gutter: int) -> void:
var line_errors = errors.filter(func(error): return error.line_number == line)
if line_errors.size() > 0:
error_clicked.emit(line)
#endregion

View File

@@ -0,0 +1 @@
uid://djeybvlb332mp

View File

@@ -0,0 +1,56 @@
[gd_scene load_steps=4 format=3 uid="uid://civ6shmka5e8u"]
[ext_resource type="Script" uid="uid://klpiq4tk3t7a" path="res://addons/dialogue_manager/components/code_edit_syntax_highlighter.gd" id="1_58cfo"]
[ext_resource type="Script" uid="uid://djeybvlb332mp" path="res://addons/dialogue_manager/components/code_edit.gd" id="1_g324i"]
[sub_resource type="SyntaxHighlighter" id="SyntaxHighlighter_cobxx"]
script = ExtResource("1_58cfo")
[node name="CodeEdit" type="CodeEdit"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
text = "~ title_thing
if this = \"that\" or 'this'
Nathan: Something
- Then [if test.thing() == 2.0] => somewhere
- Other => END!
~ somewhere
set has_something = true
=> END"
highlight_all_occurrences = true
highlight_current_line = true
draw_tabs = true
syntax_highlighter = SubResource("SyntaxHighlighter_cobxx")
scroll_past_end_of_file = true
minimap_draw = true
symbol_lookup_on_click = true
line_folding = true
gutters_draw_line_numbers = true
gutters_draw_fold_gutter = true
delimiter_strings = Array[String](["\" \""])
delimiter_comments = Array[String](["#"])
code_completion_enabled = true
code_completion_prefixes = Array[String]([">", "<"])
indent_automatic = true
auto_brace_completion_enabled = true
auto_brace_completion_highlight_matching = true
auto_brace_completion_pairs = {
"\"": "\"",
"(": ")",
"[": "]",
"{": "}"
}
script = ExtResource("1_g324i")
[connection signal="caret_changed" from="." to="." method="_on_code_edit_caret_changed"]
[connection signal="gutter_clicked" from="." to="." method="_on_code_edit_gutter_clicked"]
[connection signal="symbol_lookup" from="." to="." method="_on_code_edit_symbol_lookup"]
[connection signal="symbol_validate" from="." to="." method="_on_code_edit_symbol_validate"]
[connection signal="text_changed" from="." to="." method="_on_code_edit_text_changed"]
[connection signal="text_set" from="." to="." method="_on_code_edit_text_set"]

View File

@@ -0,0 +1,231 @@
@tool
class_name DMSyntaxHighlighter extends SyntaxHighlighter
var regex: DMCompilerRegEx = DMCompilerRegEx.new()
var compilation: DMCompilation = DMCompilation.new()
var expression_parser = DMExpressionParser.new()
var cache: Dictionary = {}
func _clear_highlighting_cache() -> void:
cache.clear()
func _get_line_syntax_highlighting(line: int) -> Dictionary:
expression_parser.include_comments = true
var colors: Dictionary = {}
var text_edit: TextEdit = get_text_edit()
var text: String = text_edit.get_line(line)
# Prevent an error from popping up while developing
if not is_instance_valid(text_edit) or text_edit.theme_overrides.is_empty():
return colors
# Disable this, as well as the line at the bottom of this function to remove the cache.
if text in cache:
return cache[text]
var theme: Dictionary = text_edit.theme_overrides
var index: int = 0
match DMCompiler.get_line_type(text):
DMConstants.TYPE_USING:
colors[index] = { color = theme.conditions_color }
colors[index + "using ".length()] = { color = theme.text_color }
DMConstants.TYPE_IMPORT:
colors[index] = { color = theme.conditions_color }
var import: RegExMatch = regex.IMPORT_REGEX.search(text)
if import:
colors[index + import.get_start("path") - 1] = { color = theme.strings_color }
colors[index + import.get_end("path") + 1] = { color = theme.conditions_color }
colors[index + import.get_start("prefix")] = { color = theme.text_color }
DMConstants.TYPE_COMMENT:
colors[index] = { color = theme.comments_color }
DMConstants.TYPE_TITLE:
colors[index] = { color = theme.titles_color }
DMConstants.TYPE_CONDITION, DMConstants.TYPE_WHILE, DMConstants.TYPE_MATCH, DMConstants.TYPE_WHEN:
colors[0] = { color = theme.conditions_color }
index = text.find(" ")
if index > -1:
var expression: Array = expression_parser.tokenise(text.substr(index), DMConstants.TYPE_CONDITION, 0)
if expression.size() == 0:
colors[index] = { color = theme.critical_color }
else:
_highlight_expression(expression, colors, index)
DMConstants.TYPE_MUTATION:
colors[0] = { color = theme.mutations_color }
index = text.find(" ")
var expression: Array = expression_parser.tokenise(text.substr(index), DMConstants.TYPE_MUTATION, 0)
if expression.size() == 0:
colors[index] = { color = theme.critical_color }
else:
_highlight_expression(expression, colors, index)
DMConstants.TYPE_GOTO:
if text.strip_edges().begins_with("%"):
colors[index] = { color = theme.symbols_color }
index = text.find(" ")
_highlight_goto(text, colors, index)
DMConstants.TYPE_RANDOM:
colors[index] = { color = theme.symbols_color }
DMConstants.TYPE_DIALOGUE, DMConstants.TYPE_RESPONSE:
if text.strip_edges().begins_with("%"):
colors[index] = { color = theme.symbols_color }
index = text.find(" ", text.find("%"))
colors[index] = { color = theme.text_color.lerp(theme.symbols_color, 0.5) }
var dialogue_text: String = text.substr(index, text.find("=>"))
# Highlight character name (but ignore ":" within line ID reference)
var split_index: int = dialogue_text.replace("\\:", "??").find(":")
if text.substr(split_index - 3, 3) != "[ID":
colors[index + split_index + 1] = { color = theme.text_color }
else:
# If there's no character name then just highlight the text as dialogue.
colors[index] = { color = theme.text_color }
# Interpolation
var replacements: Array[RegExMatch] = regex.REPLACEMENTS_REGEX.search_all(dialogue_text)
for replacement: RegExMatch in replacements:
var expression_text: String = replacement.get_string().substr(0, replacement.get_string().length() - 2).substr(2)
var expression: Array = expression_parser.tokenise(expression_text, DMConstants.TYPE_MUTATION, replacement.get_start())
var expression_index: int = index + replacement.get_start()
colors[expression_index] = { color = theme.symbols_color }
if expression.size() == 0 or expression[0].type == DMConstants.TYPE_ERROR:
colors[expression_index] = { color = theme.critical_color }
else:
_highlight_expression(expression, colors, index + 2)
colors[expression_index + expression_text.length() + 2] = { color = theme.symbols_color }
colors[expression_index + expression_text.length() + 4] = { color = theme.text_color }
# Tags (and inline mutations)
var resolved_line_data: DMResolvedLineData = DMResolvedLineData.new("")
var bbcodes: Array[Dictionary] = resolved_line_data.find_bbcode_positions_in_string(dialogue_text, true, true)
for bbcode: Dictionary in bbcodes:
var tag: String = bbcode.code
var code: String = bbcode.raw_args
if code.begins_with("["):
colors[index + bbcode.start] = { color = theme.symbols_color }
colors[index + bbcode.start + 2] = { color = theme.text_color }
var pipe_cursor: int = code.find("|")
while pipe_cursor > -1:
colors[index + bbcode.start + pipe_cursor + 1] = { color = theme.symbols_color }
colors[index + bbcode.start + pipe_cursor + 2] = { color = theme.text_color }
pipe_cursor = code.find("|", pipe_cursor + 1)
colors[index + bbcode.end - 1] = { color = theme.symbols_color }
colors[index + bbcode.end + 1] = { color = theme.text_color }
else:
colors[index + bbcode.start] = { color = theme.symbols_color }
if tag.begins_with("do") or tag.begins_with("set") or tag.begins_with("if"):
if tag.begins_with("if"):
colors[index + bbcode.start + 1] = { color = theme.conditions_color }
else:
colors[index + bbcode.start + 1] = { color = theme.mutations_color }
var expression: Array = expression_parser.tokenise(code, DMConstants.TYPE_MUTATION, bbcode.start + bbcode.code.length())
if expression.size() == 0 or expression[0].type == DMConstants.TYPE_ERROR:
colors[index + bbcode.start + tag.length() + 1] = { color = theme.critical_color }
else:
_highlight_expression(expression, colors, index + 2)
# else and closing if have no expression
elif tag.begins_with("else") or tag.begins_with("/if"):
colors[index + bbcode.start + 1] = { color = theme.conditions_color }
colors[index + bbcode.end] = { color = theme.symbols_color }
colors[index + bbcode.end + 1] = { color = theme.text_color }
# Jumps
if "=> " in text or "=>< " in text:
_highlight_goto(text, colors, index)
# Order the dictionary keys to prevent CodeEdit from having issues
var ordered_colors: Dictionary = {}
var ordered_keys: Array = colors.keys()
ordered_keys.sort()
for key_index: int in ordered_keys:
ordered_colors[key_index] = colors[key_index]
cache[text] = ordered_colors
return ordered_colors
func _highlight_expression(tokens: Array, colors: Dictionary, index: int) -> int:
var theme: Dictionary = get_text_edit().theme_overrides
var last_index: int = index
for token: Dictionary in tokens:
last_index = token.i
match token.type:
DMConstants.TOKEN_COMMENT:
colors[index + token.i] = { color = theme.comments_color }
DMConstants.TOKEN_CONDITION, DMConstants.TOKEN_AND_OR:
colors[index + token.i] = { color = theme.conditions_color }
DMConstants.TOKEN_VARIABLE:
if token.value in ["true", "false"]:
colors[index + token.i] = { color = theme.conditions_color }
else:
colors[index + token.i] = { color = theme.members_color }
DMConstants.TOKEN_OPERATOR, DMConstants.TOKEN_COLON, \
DMConstants.TOKEN_COMMA, DMConstants.TOKEN_DOT, DMConstants.TOKEN_NULL_COALESCE, \
DMConstants.TOKEN_NUMBER, DMConstants.TOKEN_ASSIGNMENT:
colors[index + token.i] = { color = theme.symbols_color }
DMConstants.TOKEN_STRING:
colors[index + token.i] = { color = theme.strings_color }
DMConstants.TOKEN_FUNCTION:
colors[index + token.i] = { color = theme.mutations_color }
colors[index + token.i + token.function.length()] = { color = theme.symbols_color }
for parameter: Array in token.value:
last_index = _highlight_expression(parameter, colors, index)
DMConstants.TOKEN_PARENS_CLOSE:
colors[index + token.i] = { color = theme.symbols_color }
DMConstants.TOKEN_DICTIONARY_REFERENCE:
colors[index + token.i] = { color = theme.members_color }
colors[index + token.i + token.variable.length()] = { color = theme.symbols_color }
last_index = _highlight_expression(token.value, colors, index)
DMConstants.TOKEN_ARRAY:
colors[index + token.i] = { color = theme.symbols_color }
for item: Array in token.value:
last_index = _highlight_expression(item, colors, index)
DMConstants.TOKEN_BRACKET_CLOSE:
colors[index + token.i] = { color = theme.symbols_color }
DMConstants.TOKEN_DICTIONARY:
colors[index + token.i] = { color = theme.symbols_color }
last_index = _highlight_expression(token.value.keys() + token.value.values(), colors, index)
DMConstants.TOKEN_BRACE_CLOSE:
colors[index + token.i] = { color = theme.symbols_color }
last_index += 1
DMConstants.TOKEN_GROUP:
last_index = _highlight_expression(token.value, colors, index)
return last_index
func _highlight_goto(text: String, colors: Dictionary, index: int) -> int:
var theme: Dictionary = get_text_edit().theme_overrides
var goto_data: DMResolvedGotoData = DMResolvedGotoData.new(text, {})
colors[goto_data.index] = { color = theme.jumps_color }
if "{{" in text:
index = text.find("{{", goto_data.index)
var last_index: int = 0
if goto_data.error:
colors[index + 2] = { color = theme.critical_color }
else:
last_index = _highlight_expression(goto_data.expression, colors, index)
index = text.find("}}", index + last_index)
colors[index] = { color = theme.jumps_color }
return index

View File

@@ -0,0 +1 @@
uid://klpiq4tk3t7a

View File

@@ -0,0 +1,84 @@
@tool
extends Control
signal failed()
signal updated(updated_to_version: String)
const DialogueConstants = preload("../constants.gd")
const TEMP_FILE_NAME = "user://temp.zip"
@onready var logo: TextureRect = %Logo
@onready var label: Label = $VBox/Label
@onready var http_request: HTTPRequest = $HTTPRequest
@onready var download_button: Button = %DownloadButton
var next_version_release: Dictionary:
set(value):
next_version_release = value
label.text = DialogueConstants.translate(&"update.is_available_for_download") % value.tag_name.substr(1)
get:
return next_version_release
func _ready() -> void:
$VBox/Center/DownloadButton.text = DialogueConstants.translate(&"update.download_update")
$VBox/Center2/NotesButton.text = DialogueConstants.translate(&"update.release_notes")
### Signals
func _on_download_button_pressed() -> void:
# Safeguard the actual dialogue manager repo from accidentally updating itself
if FileAccess.file_exists("res://tests/test_basic_dialogue.gd"):
prints("You can't update the addon from within itself.")
failed.emit()
return
http_request.request(next_version_release.zipball_url)
download_button.disabled = true
download_button.text = DialogueConstants.translate(&"update.downloading")
func _on_http_request_request_completed(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray) -> void:
if result != HTTPRequest.RESULT_SUCCESS:
failed.emit()
return
# Save the downloaded zip
var zip_file: FileAccess = FileAccess.open(TEMP_FILE_NAME, FileAccess.WRITE)
zip_file.store_buffer(body)
zip_file.close()
OS.move_to_trash(ProjectSettings.globalize_path("res://addons/dialogue_manager"))
var zip_reader: ZIPReader = ZIPReader.new()
zip_reader.open(TEMP_FILE_NAME)
var files: PackedStringArray = zip_reader.get_files()
var base_path = files[1]
# Remove archive folder
files.remove_at(0)
# Remove assets folder
files.remove_at(0)
for path in files:
var new_file_path: String = path.replace(base_path, "")
if path.ends_with("/"):
DirAccess.make_dir_recursive_absolute("res://addons/%s" % new_file_path)
else:
var file: FileAccess = FileAccess.open("res://addons/%s" % new_file_path, FileAccess.WRITE)
file.store_buffer(zip_reader.read_file(path))
zip_reader.close()
DirAccess.remove_absolute(TEMP_FILE_NAME)
updated.emit(next_version_release.tag_name.substr(1))
func _on_notes_button_pressed() -> void:
OS.shell_open(next_version_release.html_url)

View File

@@ -0,0 +1 @@
uid://kpwo418lb2t2

View File

@@ -0,0 +1,60 @@
[gd_scene load_steps=3 format=3 uid="uid://qdxrxv3c3hxk"]
[ext_resource type="Script" uid="uid://kpwo418lb2t2" path="res://addons/dialogue_manager/components/download_update_panel.gd" id="1_4tm1k"]
[ext_resource type="Texture2D" uid="uid://d3baj6rygkb3f" path="res://addons/dialogue_manager/assets/update.svg" id="2_4o2m6"]
[node name="DownloadUpdatePanel" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_4tm1k")
[node name="HTTPRequest" type="HTTPRequest" parent="."]
[node name="VBox" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -1.0
offset_top = 9.0
offset_right = -1.0
offset_bottom = 9.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 10
[node name="Logo" type="TextureRect" parent="VBox"]
unique_name_in_owner = true
clip_contents = true
custom_minimum_size = Vector2(300, 80)
layout_mode = 2
texture = ExtResource("2_4o2m6")
stretch_mode = 5
[node name="Label" type="Label" parent="VBox"]
layout_mode = 2
text = "v1.2.3 is available for download."
horizontal_alignment = 1
[node name="Center" type="CenterContainer" parent="VBox"]
layout_mode = 2
[node name="DownloadButton" type="Button" parent="VBox/Center"]
unique_name_in_owner = true
layout_mode = 2
text = "Download update"
[node name="Center2" type="CenterContainer" parent="VBox"]
layout_mode = 2
[node name="NotesButton" type="LinkButton" parent="VBox/Center2"]
layout_mode = 2
text = "Read release notes"
[connection signal="request_completed" from="HTTPRequest" to="." method="_on_http_request_request_completed"]
[connection signal="pressed" from="VBox/Center/DownloadButton" to="." method="_on_download_button_pressed"]
[connection signal="pressed" from="VBox/Center2/NotesButton" to="." method="_on_notes_button_pressed"]

View File

@@ -0,0 +1,48 @@
@tool
extends EditorProperty
const DialoguePropertyEditorControl = preload("./editor_property_control.tscn")
var editor_plugin: EditorPlugin
var control = DialoguePropertyEditorControl.instantiate()
var current_value: Resource
var is_updating: bool = false
func _init() -> void:
add_child(control)
control.resource = current_value
control.pressed.connect(_on_button_pressed)
control.resource_changed.connect(_on_resource_changed)
func _update_property() -> void:
var next_value = get_edited_object()[get_edited_property()]
# The resource might have been deleted elsewhere so check that it's not in a weird state
if is_instance_valid(next_value) and not next_value.resource_path.ends_with(".dialogue"):
emit_changed(get_edited_property(), null)
return
if next_value == current_value: return
is_updating = true
current_value = next_value
control.resource = current_value
is_updating = false
### Signals
func _on_button_pressed() -> void:
editor_plugin.edit(current_value)
func _on_resource_changed(next_resource: Resource) -> void:
emit_changed(get_edited_property(), next_resource)

View File

@@ -0,0 +1 @@
uid://nyypeje1a036

View File

@@ -0,0 +1,147 @@
@tool
extends HBoxContainer
signal pressed()
signal resource_changed(next_resource: Resource)
const ITEM_NEW = 100
const ITEM_QUICK_LOAD = 200
const ITEM_LOAD = 201
const ITEM_EDIT = 300
const ITEM_CLEAR = 301
const ITEM_FILESYSTEM = 400
@onready var button: Button = $ResourceButton
@onready var menu_button: Button = $MenuButton
@onready var menu: PopupMenu = $Menu
@onready var quick_open_dialog: ConfirmationDialog = $QuickOpenDialog
@onready var files_list = $QuickOpenDialog/FilesList
@onready var new_dialog: FileDialog = $NewDialog
@onready var open_dialog: FileDialog = $OpenDialog
var editor_plugin: EditorPlugin
var resource: Resource:
set(next_resource):
resource = next_resource
if button:
button.resource = resource
get:
return resource
var is_waiting_for_file: bool = false
var quick_selected_file: String = ""
func _ready() -> void:
menu_button.icon = get_theme_icon("GuiDropdown", "EditorIcons")
editor_plugin = Engine.get_meta("DialogueManagerPlugin")
func build_menu() -> void:
menu.clear()
menu.add_icon_item(editor_plugin._get_plugin_icon(), "New Dialogue", ITEM_NEW)
menu.add_separator()
menu.add_icon_item(get_theme_icon("Load", "EditorIcons"), "Quick Load", ITEM_QUICK_LOAD)
menu.add_icon_item(get_theme_icon("Load", "EditorIcons"), "Load", ITEM_LOAD)
if resource:
menu.add_icon_item(get_theme_icon("Edit", "EditorIcons"), "Edit", ITEM_EDIT)
menu.add_icon_item(get_theme_icon("Clear", "EditorIcons"), "Clear", ITEM_CLEAR)
menu.add_separator()
menu.add_item("Show in FileSystem", ITEM_FILESYSTEM)
menu.size = Vector2.ZERO
### Signals
func _on_new_dialog_file_selected(path: String) -> void:
editor_plugin.main_view.new_file(path)
is_waiting_for_file = false
if Engine.get_meta("DMCache").has_file(path):
resource_changed.emit(load(path))
else:
var next_resource: Resource = await editor_plugin.import_plugin.compiled_resource
next_resource.resource_path = path
resource_changed.emit(next_resource)
func _on_open_dialog_file_selected(file: String) -> void:
resource_changed.emit(load(file))
func _on_file_dialog_canceled() -> void:
is_waiting_for_file = false
func _on_resource_button_pressed() -> void:
if is_instance_valid(resource):
EditorInterface.call_deferred("edit_resource", resource)
else:
build_menu()
menu.position = get_viewport().position + Vector2i(
button.global_position.x + button.size.x - menu.size.x,
2 + menu_button.global_position.y + button.size.y
)
menu.popup()
func _on_resource_button_resource_dropped(next_resource: Resource) -> void:
resource_changed.emit(next_resource)
func _on_menu_button_pressed() -> void:
build_menu()
menu.position = get_viewport().position + Vector2i(
menu_button.global_position.x + menu_button.size.x - menu.size.x,
2 + menu_button.global_position.y + menu_button.size.y
)
menu.popup()
func _on_menu_id_pressed(id: int) -> void:
match id:
ITEM_NEW:
is_waiting_for_file = true
new_dialog.popup_centered()
ITEM_QUICK_LOAD:
quick_selected_file = ""
files_list.files = Engine.get_meta("DMCache").get_files()
if resource:
files_list.select_file(resource.resource_path)
quick_open_dialog.popup_centered()
files_list.focus_filter()
ITEM_LOAD:
is_waiting_for_file = true
open_dialog.popup_centered()
ITEM_EDIT:
EditorInterface.call_deferred("edit_resource", resource)
ITEM_CLEAR:
resource_changed.emit(null)
ITEM_FILESYSTEM:
var file_system = EditorInterface.get_file_system_dock()
file_system.navigate_to_path(resource.resource_path)
func _on_files_list_file_double_clicked(file_path: String) -> void:
resource_changed.emit(load(file_path))
quick_open_dialog.hide()
func _on_files_list_file_selected(file_path: String) -> void:
quick_selected_file = file_path
func _on_quick_open_dialog_confirmed() -> void:
if quick_selected_file != "":
resource_changed.emit(load(quick_selected_file))

View File

@@ -0,0 +1 @@
uid://dooe2pflnqtve

View File

@@ -0,0 +1,58 @@
[gd_scene load_steps=4 format=3 uid="uid://ycn6uaj7dsrh"]
[ext_resource type="Script" uid="uid://dooe2pflnqtve" path="res://addons/dialogue_manager/components/editor_property/editor_property_control.gd" id="1_het12"]
[ext_resource type="PackedScene" uid="uid://b16uuqjuof3n5" path="res://addons/dialogue_manager/components/editor_property/resource_button.tscn" id="2_hh3d4"]
[ext_resource type="PackedScene" uid="uid://dnufpcdrreva3" path="res://addons/dialogue_manager/components/files_list.tscn" id="3_l8fp6"]
[node name="PropertyEditorButton" type="HBoxContainer"]
offset_right = 40.0
offset_bottom = 40.0
size_flags_horizontal = 3
theme_override_constants/separation = 0
script = ExtResource("1_het12")
[node name="ResourceButton" parent="." instance=ExtResource("2_hh3d4")]
layout_mode = 2
text = "<empty>"
text_overrun_behavior = 3
clip_text = true
[node name="MenuButton" type="Button" parent="."]
layout_mode = 2
[node name="Menu" type="PopupMenu" parent="."]
[node name="QuickOpenDialog" type="ConfirmationDialog" parent="."]
title = "Find Dialogue Resource"
size = Vector2i(400, 600)
min_size = Vector2i(400, 600)
ok_button_text = "Open"
[node name="FilesList" parent="QuickOpenDialog" instance=ExtResource("3_l8fp6")]
[node name="NewDialog" type="FileDialog" parent="."]
size = Vector2i(900, 750)
min_size = Vector2i(900, 750)
dialog_hide_on_ok = true
filters = PackedStringArray("*.dialogue ; Dialogue")
[node name="OpenDialog" type="FileDialog" parent="."]
title = "Open a File"
size = Vector2i(900, 750)
min_size = Vector2i(900, 750)
ok_button_text = "Open"
dialog_hide_on_ok = true
file_mode = 0
filters = PackedStringArray("*.dialogue ; Dialogue")
[connection signal="pressed" from="ResourceButton" to="." method="_on_resource_button_pressed"]
[connection signal="resource_dropped" from="ResourceButton" to="." method="_on_resource_button_resource_dropped"]
[connection signal="pressed" from="MenuButton" to="." method="_on_menu_button_pressed"]
[connection signal="id_pressed" from="Menu" to="." method="_on_menu_id_pressed"]
[connection signal="confirmed" from="QuickOpenDialog" to="." method="_on_quick_open_dialog_confirmed"]
[connection signal="file_double_clicked" from="QuickOpenDialog/FilesList" to="." method="_on_files_list_file_double_clicked"]
[connection signal="file_selected" from="QuickOpenDialog/FilesList" to="." method="_on_files_list_file_selected"]
[connection signal="canceled" from="NewDialog" to="." method="_on_file_dialog_canceled"]
[connection signal="file_selected" from="NewDialog" to="." method="_on_new_dialog_file_selected"]
[connection signal="canceled" from="OpenDialog" to="." method="_on_file_dialog_canceled"]
[connection signal="file_selected" from="OpenDialog" to="." method="_on_open_dialog_file_selected"]

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