Map editor: wheel/MMB scrolling, characters and items tabs to place NPCs, enemies and pickups, events saved to map_events with other files and mods shown locked, LF output
This commit is contained in:
1 parent
eaf58c0d38
commit
1d6904bb16
14 files changed
+863
-67
No files matched your search
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
using Cthangover.Mods.Rpg;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Tools.RpgEditor
|
||||
{
|
||||
/// <summary>
|
||||
/// The characters the running game knows (<c>Kind: character</c> of every
|
||||
/// loaded mod), sorted by display name, for placing them on a map.
|
||||
/// <para/>
|
||||
/// A character has no map look of its own, so its charset is found by name:
|
||||
/// an explicit <c>"Charset"</c> field of the character (kept by the
|
||||
/// character's extra properties; the character editor sets it) wins, otherwise the
|
||||
/// portrait path in the <c>characters</c> group (<c>characters/village/slime</c> or
|
||||
/// <c>village/slime</c> → <c>village/slime</c>), as village_pack names both. Only charsets that exist are used: the texture
|
||||
/// loader logs an error for a missing one. The row picture is the middle frame
|
||||
/// of the "down" row, how the character stands on the map.
|
||||
/// </summary>
|
||||
public static class RpgCharacterCatalog
|
||||
{
|
||||
private const string ImageGroup = "characters";
|
||||
|
||||
/// <summary>Reads the character list anew.</summary>
|
||||
public static List<RpgPickerEntry> Load(RpgTextures textures)
|
||||
{
|
||||
var charsets = RpgItemCatalog.GroupFiles(RpgTextures.CharsetGroup);
|
||||
return (Entities.Collect<CharacterInfo>()?.Values ?? Enumerable.Empty<CharacterInfo>())
|
||||
.Where(info => !string.IsNullOrEmpty(info.ID))
|
||||
.Select(info => Entry(info, CharsetOf(info, charsets), textures))
|
||||
.OrderBy(entry => entry.Name, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static RpgPickerEntry Entry(CharacterInfo info, string charset, RpgTextures textures) => new()
|
||||
{
|
||||
Id = info.ID,
|
||||
Name = string.IsNullOrEmpty(info.Name) ? info.ID : TranslationServer.Translate(info.Name).ToString(),
|
||||
Look = charset,
|
||||
Icon = FrontFrame(charset == null ? null : textures.Charset(charset))
|
||||
};
|
||||
|
||||
private static string CharsetOf(CharacterInfo info, HashSet<string> charsets)
|
||||
{
|
||||
object explicitCharset = null;
|
||||
info.ExtraProperties?.TryGetValue("Charset", out explicitCharset);
|
||||
var image = info.Image ?? string.Empty;
|
||||
if (image.StartsWith(ImageGroup + "/", StringComparison.OrdinalIgnoreCase))
|
||||
image = image.Substring(ImageGroup.Length + 1);
|
||||
var candidates = new[] { explicitCharset?.ToString(), image };
|
||||
return candidates.FirstOrDefault(path => !string.IsNullOrEmpty(path) && charsets.Contains(path));
|
||||
}
|
||||
|
||||
private static Texture2D FrontFrame(Texture2D image)
|
||||
{
|
||||
if (image == null)
|
||||
return null;
|
||||
var frame = new Vector2(image.GetWidth() / 3f, image.GetHeight() / 4f);
|
||||
return new AtlasTexture { Atlas = image, Region = new Rect2(new Vector2(frame.X, 0f), frame) };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cthangover.Mods.Interface.Theming;
|
||||
using Cthangover.Tools.Common;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Tools.RpgEditor
|
||||
{
|
||||
/// <summary>
|
||||
/// A tab of the editor's left column listing entities of the game (the
|
||||
/// characters, the items) with a picture, a search over name and id and
|
||||
/// buttons that act on the selected row. The window decides what the
|
||||
/// entries are (<see cref="Source"/>) and what the buttons do
|
||||
/// (<see cref="AddAction"/>); placing on the map is then a click on a cell.
|
||||
/// <para/>
|
||||
/// The list is read again on every <see cref="Reload"/> (the window calls it
|
||||
/// whenever the tab is opened), so entities added to the game's data while
|
||||
/// the editor is open show up.
|
||||
/// </summary>
|
||||
public partial class RpgEntityPicker : VBoxContainer
|
||||
{
|
||||
private readonly List<RpgPickerEntry> shown = new();
|
||||
private readonly List<Button> actions = new();
|
||||
private readonly LineEdit search;
|
||||
private readonly ItemList list;
|
||||
private readonly HBoxContainer options;
|
||||
private readonly HBoxContainer buttons;
|
||||
private List<RpgPickerEntry> all = new();
|
||||
|
||||
/// <summary>Builds the search field, the list, the option and button rows and the hint.</summary>
|
||||
public RpgEntityPicker(string searchKey, string hintKey)
|
||||
{
|
||||
search = ToolUi.Text(ToolUi.Tr(searchKey), OnSearchChanged);
|
||||
AddChild(search);
|
||||
|
||||
list = new ItemList
|
||||
{
|
||||
AutoHeight = true,
|
||||
FixedIconSize = new Vector2I(32, 48),
|
||||
TextureFilter = TextureFilterEnum.Nearest,
|
||||
SizeFlagsHorizontal = SizeFlags.ExpandFill
|
||||
};
|
||||
list.ItemSelected += OnItemSelected;
|
||||
AddChild(list);
|
||||
|
||||
options = new HBoxContainer();
|
||||
AddChild(options);
|
||||
buttons = new HBoxContainer();
|
||||
AddChild(buttons);
|
||||
AddChild(ToolUi.Hint(ToolUi.Tr(hintKey)));
|
||||
}
|
||||
|
||||
/// <summary>Reads the entries; called by <see cref="Reload"/>.</summary>
|
||||
public Func<List<RpgPickerEntry>> Source { get; set; }
|
||||
|
||||
/// <summary>The selected entry, or null.</summary>
|
||||
public RpgPickerEntry Selected
|
||||
{
|
||||
get
|
||||
{
|
||||
var picked = list.GetSelectedItems();
|
||||
return picked.Length == 0 ? null : shown[picked[0]];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Adds a button acting on the selected entry; it is disabled while nothing is selected.</summary>
|
||||
public void AddAction(string textKey, string tooltipKey, Action<RpgPickerEntry> action)
|
||||
{
|
||||
var button = ToolUi.Button(textKey, null, ButtonKind.Secondary, tooltipKey);
|
||||
button.Pressed += () => Run(action);
|
||||
button.Disabled = true;
|
||||
actions.Add(button);
|
||||
buttons.AddChild(button);
|
||||
}
|
||||
|
||||
/// <summary>Adds a control (a count, a mode) to the row above the buttons.</summary>
|
||||
public void AddOption(Control control) => options.AddChild(control);
|
||||
|
||||
/// <summary>Reads the entries again and reapplies the search.</summary>
|
||||
public void Reload()
|
||||
{
|
||||
all = Source?.Invoke() ?? new List<RpgPickerEntry>();
|
||||
Filter();
|
||||
}
|
||||
|
||||
private void Filter()
|
||||
{
|
||||
var selectedId = Selected?.Id;
|
||||
var query = search.Text.Trim();
|
||||
shown.Clear();
|
||||
shown.AddRange(all.Where(entry => query.Length == 0
|
||||
|| entry.Name.Contains(query, StringComparison.CurrentCultureIgnoreCase)
|
||||
|| entry.Id.Contains(query, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
list.Clear();
|
||||
foreach (var entry in shown)
|
||||
{
|
||||
var index = list.AddItem($"{entry.Name} · {entry.Id}", entry.Icon);
|
||||
list.SetItemTooltip(index, entry.Look ?? ToolUi.Tr("tools/rpg_editor/picker_no_look"));
|
||||
if (entry.Id == selectedId)
|
||||
list.Select(index);
|
||||
}
|
||||
UpdateButtons();
|
||||
}
|
||||
|
||||
private void Run(Action<RpgPickerEntry> action)
|
||||
{
|
||||
if (Selected != null)
|
||||
action(Selected);
|
||||
}
|
||||
|
||||
private void UpdateButtons()
|
||||
{
|
||||
foreach (var button in actions)
|
||||
button.Disabled = Selected == null;
|
||||
}
|
||||
|
||||
private void OnSearchChanged(string text) => Filter();
|
||||
|
||||
private void OnItemSelected(long index) => UpdateButtons();
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ using Godot;
|
||||
namespace Cthangover.Tools.RpgEditor
|
||||
{
|
||||
/// <summary>
|
||||
/// Properties of the selected map event: id, cell, look (charset path or
|
||||
/// tileset index), facing, trigger, solidity, what it runs (scenario path,
|
||||
/// Properties of the selected map event: id, cell, look (charset path, tileset index
|
||||
/// or item picture), facing, trigger, solidity, what it runs (scenario path,
|
||||
/// inline DSL lines, or a teleport) and the condition that shows it.
|
||||
/// <para/>
|
||||
/// A teleport is on when its map or cell is filled in; clearing the teleport
|
||||
@@ -29,6 +29,7 @@ namespace Cthangover.Tools.RpgEditor
|
||||
private readonly SpinBox ySpin;
|
||||
private readonly LineEdit spriteEdit;
|
||||
private readonly SpinBox tileSpin;
|
||||
private readonly LineEdit iconEdit;
|
||||
private readonly OptionButton dirPicker;
|
||||
private readonly OptionButton triggerPicker;
|
||||
private readonly OptionButton solidPicker;
|
||||
@@ -66,6 +67,7 @@ namespace Cthangover.Tools.RpgEditor
|
||||
ySpin = ToolUi.Field(body, "tools/rpg_editor/y", ToolUi.Number(0, 255, 1, OnNumberChanged));
|
||||
spriteEdit = ToolUi.Field(body, "tools/rpg_editor/sprite", ToolUi.Text("village/elder", OnTextChanged), "tools/rpg_editor/sprite_tip");
|
||||
tileSpin = ToolUi.Field(body, "tools/rpg_editor/tile", ToolUi.Number(-1, 4095, 1, OnNumberChanged), "tools/rpg_editor/tile_tip");
|
||||
iconEdit = ToolUi.Field(body, "tools/rpg_editor/icon", ToolUi.Text("rpg_demo/branch_bundle", OnTextChanged), "tools/rpg_editor/icon_tip");
|
||||
dirPicker = ToolUi.Field(body, "tools/rpg_editor/dir", RpgEditorFields.Direction(OnDirectionPicked));
|
||||
triggerPicker = ToolUi.Field(body, "tools/rpg_editor/trigger", RpgEditorFields.Choice(TriggerKeys, OnChoicePicked));
|
||||
solidPicker = ToolUi.Field(body, "tools/rpg_editor/solid", RpgEditorFields.Choice(SolidKeys, OnChoicePicked));
|
||||
@@ -106,6 +108,7 @@ namespace Cthangover.Tools.RpgEditor
|
||||
ySpin.Value = value.Y;
|
||||
spriteEdit.Text = value.Sprite ?? string.Empty;
|
||||
tileSpin.Value = value.Tile;
|
||||
iconEdit.Text = value.Icon ?? string.Empty;
|
||||
dirPicker.Select((int)value.Dir);
|
||||
triggerPicker.Select((int)value.Trigger);
|
||||
solidPicker.Select(value.Solid == null ? 0 : value.Solid.Value ? 1 : 2);
|
||||
@@ -161,6 +164,7 @@ namespace Cthangover.Tools.RpgEditor
|
||||
mapEvent.Y = (int)ySpin.Value;
|
||||
mapEvent.Sprite = NullIfEmpty(spriteEdit.Text);
|
||||
mapEvent.Tile = (int)tileSpin.Value;
|
||||
mapEvent.Icon = NullIfEmpty(iconEdit.Text);
|
||||
mapEvent.Dir = (RpgDirection)dirPicker.Selected;
|
||||
mapEvent.Trigger = (RpgEventTrigger)triggerPicker.Selected;
|
||||
mapEvent.Solid = solidPicker.Selected switch { 1 => true, 2 => false, _ => null };
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Items;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
using Cthangover.Mods.Rpg;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Tools.RpgEditor
|
||||
{
|
||||
/// <summary>
|
||||
/// The items the running game knows (<c>Kind: item</c> of every loaded mod),
|
||||
/// sorted by display name, for laying them on a map. The look is the item's
|
||||
/// own <c>Sprite</c> in the <c>items</c> group, the picture the inventory
|
||||
/// shows; an item whose picture is missing gets none (the texture loader
|
||||
/// would log an error for it).
|
||||
/// </summary>
|
||||
public static class RpgItemCatalog
|
||||
{
|
||||
/// <summary>Reads the item list anew.</summary>
|
||||
public static List<RpgPickerEntry> Load(RpgTextures textures)
|
||||
{
|
||||
var pictures = GroupFiles(RpgTextures.IconGroup);
|
||||
return (Entities.Collect<ItemInfo>()?.Values ?? Enumerable.Empty<ItemInfo>())
|
||||
.Where(info => !string.IsNullOrEmpty(info.ID))
|
||||
.Select(info => Entry(info, pictures.Contains(info.Sprite ?? string.Empty) ? info.Sprite : null, textures))
|
||||
.OrderBy(entry => entry.Name, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Paths of a file group without extensions, case-insensitive: what exists to be loaded.</summary>
|
||||
public static HashSet<string> GroupFiles(string group)
|
||||
{
|
||||
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var files = Files.Collect(group);
|
||||
if (files != null)
|
||||
{
|
||||
foreach (var key in files.Keys)
|
||||
paths.Add(Path.ChangeExtension(key, null));
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
private static RpgPickerEntry Entry(ItemInfo info, string picture, RpgTextures textures) => new()
|
||||
{
|
||||
Id = info.ID,
|
||||
Name = string.IsNullOrEmpty(info.Name) ? info.ID : TranslationServer.Translate(info.Name).ToString(),
|
||||
Look = picture,
|
||||
Icon = picture == null ? null : textures.Icon(picture)
|
||||
};
|
||||
}
|
||||
}
|
||||
+122
-20
@@ -10,12 +10,19 @@ namespace Cthangover.Tools.RpgEditor
|
||||
{
|
||||
/// <summary>
|
||||
/// The map being edited, free of any UI: the <see cref="RpgMap"/> with its
|
||||
/// cells parsed into an <see cref="RpgMapGrid"/>, the edit operations of the
|
||||
/// window and an undo history.
|
||||
/// cells parsed into an <see cref="RpgMapGrid"/>, its events, the edit
|
||||
/// operations of the window and an undo history.
|
||||
/// <para/>
|
||||
/// Events come in two parts. <see cref="Events"/> are the editor's own file of
|
||||
/// the map's mod, <see cref="EventsPath"/>; the editor changes and saves only
|
||||
/// them, so a beginner never deals with event files. <see cref="LockedEvents"/>
|
||||
/// are all the others the game shows on this map: events other mods add, and
|
||||
/// hand-written files of the same mod (an author may split events over files).
|
||||
/// They are drawn, never edited, and no new event may take their ids.
|
||||
/// <para/>
|
||||
/// Undo works on whole states: before an edit the window calls
|
||||
/// <see cref="BeginChange"/>, which stores the grid and the map's other data
|
||||
/// (events, spawn, size…) as JSON. A paint stroke is one change, however many
|
||||
/// (own events, spawn, size…) as JSON. A paint stroke is one change, however many
|
||||
/// cells it touches. The grid is the only source of the cells while editing;
|
||||
/// <see cref="ToJson"/> writes it back into the map rows.
|
||||
/// </summary>
|
||||
@@ -26,9 +33,15 @@ namespace Cthangover.Tools.RpgEditor
|
||||
private readonly List<RpgMapSnapshot> undo = new();
|
||||
private readonly List<RpgMapSnapshot> redo = new();
|
||||
|
||||
/// <summary>Map data (events, spawn, encounters…); its layer rows are stale until <see cref="ToJson"/>.</summary>
|
||||
/// <summary>Map data (spawn, encounters…); its layer rows are stale until <see cref="ToJson"/>.</summary>
|
||||
public RpgMap Map { get; private set; }
|
||||
|
||||
/// <summary>Events of the editor's own file (<see cref="EventsPath"/>), in drawing order.</summary>
|
||||
public List<RpgMapEvent> Events { get; private set; } = new();
|
||||
|
||||
/// <summary>Events of the map from other files and mods: shown, not edited (<see cref="SetLockedEvents"/>).</summary>
|
||||
public IReadOnlyList<RpgMapEvent> LockedEvents { get; private set; } = new List<RpgMapEvent>();
|
||||
|
||||
/// <summary>The cells.</summary>
|
||||
public RpgMapGrid Grid { get; private set; }
|
||||
|
||||
@@ -41,7 +54,6 @@ namespace Cthangover.Tools.RpgEditor
|
||||
private RpgMapDocument(RpgMap map)
|
||||
{
|
||||
Map = map;
|
||||
Map.Events ??= new List<RpgMapEvent>();
|
||||
Map.Spawn ??= new RpgTeleport();
|
||||
Grid = RpgMapGrid.Parse(map);
|
||||
}
|
||||
@@ -64,24 +76,41 @@ namespace Cthangover.Tools.RpgEditor
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a map file (<c>{"Kind":"rpg_map","Items":[…]}</c>, the first item).
|
||||
/// Reads a map file (<c>{"Kind":"rpg_map","Items":[…]}</c>, the first item),
|
||||
/// and the editor's events file (<c>{"Events":[…]}</c>; null or empty for none).
|
||||
/// Throws <see cref="JsonException"/> on broken JSON and
|
||||
/// <see cref="InvalidOperationException"/> on a file without maps, so the
|
||||
/// window can show the reason instead of opening an empty map.
|
||||
/// </summary>
|
||||
public static RpgMapDocument Load(string json)
|
||||
public static RpgMapDocument Load(string json, string eventsJson = null)
|
||||
{
|
||||
var data = JsonSerializer.Deserialize<FileData<RpgMap>>(json, JsonOptionsProvider.ReadOptions);
|
||||
var map = data?.Items?.FirstOrDefault() ?? throw new InvalidOperationException("the file has no rpg_map item");
|
||||
return new RpgMapDocument(map);
|
||||
var document = new RpgMapDocument(map);
|
||||
if (!string.IsNullOrWhiteSpace(eventsJson))
|
||||
document.Events = JsonSerializer.Deserialize<RpgMapEventFile>(eventsJson, JsonOptionsProvider.ReadOptions)?.Events ?? new List<RpgMapEvent>();
|
||||
return document;
|
||||
}
|
||||
|
||||
/// <summary>The map file text: cells written into rows, nulls left out, indented.</summary>
|
||||
/// <summary>Sets the map's events from other files and mods (shown, not edited, not undone).</summary>
|
||||
public void SetLockedEvents(IReadOnlyList<RpgMapEvent> events) => LockedEvents = events ?? new List<RpgMapEvent>();
|
||||
|
||||
/// <summary>Mod-relative path of the editor's events file of a map: <c>map_events/<id>/events.json</c>.</summary>
|
||||
public static string EventsPath(string mapId) => $"{RpgMapEventResolver.Directory}/{mapId}/events.json";
|
||||
|
||||
/// <summary>The map file text: cells written into rows, nulls left out, indented, LF line breaks on every OS.</summary>
|
||||
public string ToJson()
|
||||
{
|
||||
Grid.WriteTo(Map);
|
||||
var data = new FileData<RpgMap> { Kind = "rpg_map", Items = new List<RpgMap> { Map } };
|
||||
return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions);
|
||||
return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions).Replace("\r\n", "\n");
|
||||
}
|
||||
|
||||
/// <summary>The events file text (<see cref="EventsPath"/>): the own events, nulls left out, indented.</summary>
|
||||
public string EventsToJson()
|
||||
{
|
||||
var data = new RpgMapEventFile { Events = Events };
|
||||
return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions).Replace("\r\n", "\n");
|
||||
}
|
||||
|
||||
/// <summary>Remembers the current state as one undo step and forgets the redo branch.</summary>
|
||||
@@ -152,7 +181,7 @@ namespace Cthangover.Tools.RpgEditor
|
||||
/// <summary>The events on a cell, top-most (last) first.</summary>
|
||||
public List<RpgMapEvent> EventsAt(int x, int y)
|
||||
{
|
||||
var found = Map.Events.Where(e => e.X == x && e.Y == y).ToList();
|
||||
var found = Events.Where(e => e.X == x && e.Y == y).ToList();
|
||||
found.Reverse();
|
||||
return found;
|
||||
}
|
||||
@@ -161,21 +190,90 @@ namespace Cthangover.Tools.RpgEditor
|
||||
public RpgMapEvent AddEvent(int x, int y)
|
||||
{
|
||||
var number = 1;
|
||||
while (Map.Events.Any(e => string.Equals(e.Id, "event_" + number, StringComparison.OrdinalIgnoreCase)))
|
||||
while (IdTaken("event_" + number))
|
||||
number++;
|
||||
var mapEvent = new RpgMapEvent { Id = "event_" + number, X = x, Y = y };
|
||||
Map.Events.Add(mapEvent);
|
||||
Events.Add(mapEvent);
|
||||
return mapEvent;
|
||||
}
|
||||
|
||||
/// <summary>Removes an event; false when it is not on this map.</summary>
|
||||
public bool RemoveEvent(RpgMapEvent mapEvent) => Map.Events.Remove(mapEvent);
|
||||
/// <summary>
|
||||
/// Puts a character of the game (<c>Kind: character</c>) on a cell as an
|
||||
/// action event looking like <paramref name="charset"/> (null: the editor's
|
||||
/// marker). The event id is the character id, numbered (<c>_2</c>, <c>_3</c>…)
|
||||
/// when taken. An enemy starts <c>rpg.battle</c> with that character on E/F
|
||||
/// and vanishes once beaten: its win flag <c><map>_<event>_won</c> is
|
||||
/// also its hiding condition, so every placed enemy is beaten separately.
|
||||
/// A non-enemy gets no lines; the author writes its dialog.
|
||||
/// </summary>
|
||||
public RpgMapEvent AddCharacter(int x, int y, string characterId, string charset, bool enemy)
|
||||
{
|
||||
var id = FreeId(characterId);
|
||||
var mapEvent = new RpgMapEvent { Id = id, X = x, Y = y, Sprite = charset };
|
||||
if (enemy)
|
||||
{
|
||||
var flag = $"{Map.ID}_{id}_won";
|
||||
mapEvent.Commands = new List<string> { $"action rpg.battle enemies={characterId} win_flag={flag}", "end" };
|
||||
mapEvent.Condition = flag + " != 1";
|
||||
}
|
||||
Events.Add(mapEvent);
|
||||
return mapEvent;
|
||||
}
|
||||
|
||||
/// <summary>Changes the map size; events left outside are moved to the nearest edge cell.</summary>
|
||||
/// <summary>
|
||||
/// Lays an item of the game (<c>Kind: item</c>) on a cell to be picked up by
|
||||
/// stepping on it, the way the demo's forest pickups work: the event shows
|
||||
/// <paramref name="icon"/> (the item's picture), is not solid, says
|
||||
/// <paramref name="line"/>, adds <paramref name="count"/> of the item and sets
|
||||
/// its own flag <c><map>_<event>_taken</c>, which hides it for good.
|
||||
/// The event id is the item id with every character a flag name cannot hold
|
||||
/// turned into <c>_</c> (<c>rpg_demo/branches</c> → <c>rpg_demo_branches</c>),
|
||||
/// numbered when taken.
|
||||
/// </summary>
|
||||
public RpgMapEvent AddItem(int x, int y, string itemId, string icon, int count, string line)
|
||||
{
|
||||
var id = FreeId(new string(itemId.Select(c => char.IsLetterOrDigit(c) || c == '_' ? c : '_').ToArray()));
|
||||
var flag = $"{Map.ID}_{id}_taken";
|
||||
var mapEvent = new RpgMapEvent
|
||||
{
|
||||
Id = id,
|
||||
X = x,
|
||||
Y = y,
|
||||
Icon = icon,
|
||||
Trigger = RpgEventTrigger.Touch,
|
||||
Solid = false,
|
||||
Commands = new List<string>
|
||||
{
|
||||
$"ptext \"{line.Replace("\"", "'")}\"",
|
||||
$"action item.add id={itemId} count={Math.Max(1, count)}",
|
||||
$"flag {flag} 1",
|
||||
"end"
|
||||
},
|
||||
Condition = flag + " != 1"
|
||||
};
|
||||
Events.Add(mapEvent);
|
||||
return mapEvent;
|
||||
}
|
||||
|
||||
/// <summary><paramref name="wanted"/> when no event has it (ignoring case), else the first free <c>wanted_N</c> from 2.</summary>
|
||||
private string FreeId(string wanted)
|
||||
{
|
||||
var id = wanted;
|
||||
for (var number = 2; IdTaken(id); number++)
|
||||
id = wanted + "_" + number;
|
||||
return id;
|
||||
}
|
||||
|
||||
private bool IdTaken(string id) => Events.Concat(LockedEvents).Any(e => string.Equals(e.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>Removes an event; false when it is not on this map.</summary>
|
||||
public bool RemoveEvent(RpgMapEvent mapEvent) => Events.Remove(mapEvent);
|
||||
|
||||
/// <summary>Changes the map size; own events left outside are moved to the nearest edge cell.</summary>
|
||||
public void Resize(int width, int height)
|
||||
{
|
||||
Grid.Resize(width, height);
|
||||
foreach (var mapEvent in Map.Events)
|
||||
foreach (var mapEvent in Events)
|
||||
{
|
||||
mapEvent.X = Math.Clamp(mapEvent.X, 0, Grid.Width - 1);
|
||||
mapEvent.Y = Math.Clamp(mapEvent.Y, 0, Grid.Height - 1);
|
||||
@@ -193,12 +291,15 @@ namespace Cthangover.Tools.RpgEditor
|
||||
var problems = new List<(string, string)>();
|
||||
if (string.IsNullOrWhiteSpace(Map.Tileset))
|
||||
problems.Add(("tools/rpg_editor/problem_tileset", Map.ID));
|
||||
foreach (var mapEvent in Map.Events)
|
||||
foreach (var mapEvent in Events)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(mapEvent.Id))
|
||||
problems.Add(("tools/rpg_editor/problem_event_id", $"{mapEvent.X},{mapEvent.Y}"));
|
||||
var locked = LockedEvents.FirstOrDefault(e => string.Equals(e.Id, mapEvent.Id, StringComparison.OrdinalIgnoreCase));
|
||||
if (locked != null)
|
||||
problems.Add(("tools/rpg_editor/problem_event_locked", $"{mapEvent.Id} ({locked.ModId}/{locked.File})"));
|
||||
}
|
||||
foreach (var group in Map.Events.Where(e => !string.IsNullOrWhiteSpace(e.Id)).GroupBy(e => e.Id, StringComparer.OrdinalIgnoreCase))
|
||||
foreach (var group in Events.Where(e => !string.IsNullOrWhiteSpace(e.Id)).GroupBy(e => e.Id, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (group.Count() > 1)
|
||||
problems.Add(("tools/rpg_editor/problem_event_duplicate", group.Key));
|
||||
@@ -213,7 +314,7 @@ namespace Cthangover.Tools.RpgEditor
|
||||
|
||||
private RpgMapSnapshot Snapshot()
|
||||
{
|
||||
return new RpgMapSnapshot(Grid.Clone(), JsonSerializer.Serialize(Map));
|
||||
return new RpgMapSnapshot(Grid.Clone(), JsonSerializer.Serialize(Map), JsonSerializer.Serialize(Events));
|
||||
}
|
||||
|
||||
private bool Swap(List<RpgMapSnapshot> from, List<RpgMapSnapshot> to)
|
||||
@@ -225,6 +326,7 @@ namespace Cthangover.Tools.RpgEditor
|
||||
from.RemoveAt(from.Count - 1);
|
||||
Grid = state.Grid.Clone();
|
||||
Map = JsonSerializer.Deserialize<RpgMap>(state.MapJson);
|
||||
Events = JsonSerializer.Deserialize<List<RpgMapEvent>>(state.EventsJson);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+120
-23
@@ -16,7 +16,12 @@ namespace Cthangover.Tools.RpgEditor
|
||||
/// a drag paints a stroke, RMB erases, Alt+LMB picks the tile under the
|
||||
/// pointer. On the events layer: LMB selects the top event of a cell and drags
|
||||
/// it, a double click on an empty cell adds an event, RMB clears the selection.
|
||||
/// Ctrl+wheel zooms.
|
||||
/// Locked events (other mods' and other files') are drawn faded with a dashed
|
||||
/// frame and ignore the mouse.
|
||||
/// While <see cref="Placing"/>, a click only chooses a cell for the window.
|
||||
/// Ctrl+wheel zooms. The plain wheel (Shift+wheel horizontally) is left to
|
||||
/// the surrounding ScrollContainer, and a middle-button drag pans it, so a
|
||||
/// map larger than the view is reachable on any layer and with any tool.
|
||||
/// <para/>
|
||||
/// The canvas owns no data. It edits the window's
|
||||
/// <see cref="RpgMapDocument"/> and reports through events; every stroke or
|
||||
@@ -29,12 +34,16 @@ namespace Cthangover.Tools.RpgEditor
|
||||
private static readonly Color OpenColor = new(0.2f, 0.85f, 0.35f, 0.4f);
|
||||
private static readonly Color GridColor = new(1f, 1f, 1f, 0.12f);
|
||||
private static readonly Color MarkerColor = new(0.35f, 0.55f, 1f, 0.55f);
|
||||
private static readonly Color LockedModulate = new(1f, 1f, 1f, 0.55f);
|
||||
|
||||
private readonly RpgTextures textures = new();
|
||||
private Vector2I? hover;
|
||||
private bool stroking;
|
||||
private RpgMapEvent dragged;
|
||||
private bool dragMoved;
|
||||
private bool panning;
|
||||
private Vector2 panStartMouse;
|
||||
private Vector2 panStartScroll;
|
||||
|
||||
/// <summary>The edited map; null shows an empty canvas.</summary>
|
||||
public RpgMapDocument Document { get; set; }
|
||||
@@ -78,6 +87,15 @@ namespace Cthangover.Tools.RpgEditor
|
||||
/// <summary>Alt+click picked a tile.</summary>
|
||||
public event Action<int> TilePicked;
|
||||
|
||||
/// <summary>
|
||||
/// The next LMB on a cell is not an edit but a choice reported through
|
||||
/// <see cref="CellChosen"/> (placing a character); RMB cancels. Either ends it.
|
||||
/// </summary>
|
||||
public bool Placing { get; set; }
|
||||
|
||||
/// <summary>The cell chosen while <see cref="Placing"/>, or null when cancelled.</summary>
|
||||
public event Action<Vector2I?> CellChosen;
|
||||
|
||||
/// <summary>Creates the canvas; input comes through the GuiInput event.</summary>
|
||||
public RpgMapEditorCanvas()
|
||||
{
|
||||
@@ -147,30 +165,55 @@ namespace Cthangover.Tools.RpgEditor
|
||||
|
||||
private void DrawEvents()
|
||||
{
|
||||
foreach (var mapEvent in Document.Map.Events)
|
||||
{
|
||||
var rect = CellRect(mapEvent.X, mapEvent.Y);
|
||||
var charset = string.IsNullOrEmpty(mapEvent.Sprite) ? null : textures.Charset(mapEvent.Sprite);
|
||||
if (charset != null)
|
||||
{
|
||||
var frame = new Vector2(charset.GetWidth() / 3f, charset.GetHeight() / 4f);
|
||||
var source = new Rect2(new Vector2(frame.X, frame.Y * (int)mapEvent.Dir), frame);
|
||||
var size = frame * Zoom;
|
||||
var target = new Rect2(rect.Position + new Vector2((rect.Size.X - size.X) / 2f, rect.Size.Y - size.Y), size);
|
||||
DrawTextureRectRegion(charset, target, source);
|
||||
}
|
||||
else if (mapEvent.Tile >= 0 && Atlas != null && Tileset != null)
|
||||
{
|
||||
DrawTextureRectRegion(Atlas, rect, TileRegion(mapEvent.Tile));
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawRect(rect.Grow(-CellSize * 0.15f), MarkerColor);
|
||||
}
|
||||
foreach (var mapEvent in Document.LockedEvents)
|
||||
DrawEvent(mapEvent, true);
|
||||
foreach (var mapEvent in Document.Events)
|
||||
DrawEvent(mapEvent, false);
|
||||
}
|
||||
|
||||
if (ActiveLayer == RpgEditLayer.Events)
|
||||
DrawRect(rect, mapEvent == SelectedEvent ? UiTokens.AccentBright : UiTokens.Alpha(UiTokens.White, 0.5f), false, mapEvent == SelectedEvent ? 3f : 1f);
|
||||
/// <summary>One event: its look, then on the events layer a frame (dashed grey for a locked one).</summary>
|
||||
private void DrawEvent(RpgMapEvent mapEvent, bool locked)
|
||||
{
|
||||
var modulate = locked ? LockedModulate : Colors.White;
|
||||
var rect = CellRect(mapEvent.X, mapEvent.Y);
|
||||
var charset = string.IsNullOrEmpty(mapEvent.Sprite) ? null : textures.Charset(mapEvent.Sprite);
|
||||
if (charset != null)
|
||||
{
|
||||
var frame = new Vector2(charset.GetWidth() / 3f, charset.GetHeight() / 4f);
|
||||
var source = new Rect2(new Vector2(frame.X, frame.Y * (int)mapEvent.Dir), frame);
|
||||
var size = frame * Zoom;
|
||||
var target = new Rect2(rect.Position + new Vector2((rect.Size.X - size.X) / 2f, rect.Size.Y - size.Y), size);
|
||||
DrawTextureRectRegion(charset, target, source, modulate);
|
||||
}
|
||||
else if (mapEvent.Tile >= 0 && Atlas != null && Tileset != null)
|
||||
{
|
||||
DrawTextureRectRegion(Atlas, rect, TileRegion(mapEvent.Tile), modulate);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(mapEvent.Icon) && textures.Icon(mapEvent.Icon) is { } icon)
|
||||
{
|
||||
// Fitted like RpgActor.UseIcon: three quarters of the cell, centred.
|
||||
var fit = CellSize * 0.75f / Mathf.Max(1, Mathf.Max(icon.GetWidth(), icon.GetHeight()));
|
||||
var size = icon.GetSize() * fit;
|
||||
DrawTextureRect(icon, new Rect2(rect.GetCenter() - size / 2f, size), false, modulate);
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawRect(rect.Grow(-CellSize * 0.15f), MarkerColor * modulate);
|
||||
}
|
||||
|
||||
if (ActiveLayer == RpgEditLayer.Events && locked)
|
||||
DrawDashedRect(rect.Grow(-1f));
|
||||
else if (ActiveLayer == RpgEditLayer.Events)
|
||||
DrawRect(rect, mapEvent == SelectedEvent ? UiTokens.AccentBright : UiTokens.Alpha(UiTokens.White, 0.5f), false, mapEvent == SelectedEvent ? 3f : 1f);
|
||||
}
|
||||
|
||||
private void DrawDashedRect(Rect2 rect)
|
||||
{
|
||||
var color = UiTokens.Alpha(UiTokens.White, 0.6f);
|
||||
DrawDashedLine(rect.Position, new Vector2(rect.End.X, rect.Position.Y), color, 1f, 4f);
|
||||
DrawDashedLine(new Vector2(rect.End.X, rect.Position.Y), rect.End, color, 1f, 4f);
|
||||
DrawDashedLine(rect.End, new Vector2(rect.Position.X, rect.End.Y), color, 1f, 4f);
|
||||
DrawDashedLine(new Vector2(rect.Position.X, rect.End.Y), rect.Position, color, 1f, 4f);
|
||||
}
|
||||
|
||||
private void DrawSpawn()
|
||||
@@ -228,6 +271,12 @@ namespace Cthangover.Tools.RpgEditor
|
||||
|
||||
if (@event is InputEventMouseMotion motion)
|
||||
{
|
||||
if (panning)
|
||||
{
|
||||
Pan(motion);
|
||||
AcceptEvent();
|
||||
return;
|
||||
}
|
||||
hover = CellAt(motion.Position);
|
||||
if (stroking && (motion.ButtonMask & (MouseButtonMask.Left | MouseButtonMask.Right)) != 0)
|
||||
Apply(hover.Value, (motion.ButtonMask & MouseButtonMask.Right) != 0);
|
||||
@@ -246,6 +295,15 @@ namespace Cthangover.Tools.RpgEditor
|
||||
AcceptEvent();
|
||||
return;
|
||||
}
|
||||
// Not accepted: the event bubbles up and the ScrollContainer scrolls.
|
||||
if (button.ButtonIndex is MouseButton.WheelUp or MouseButton.WheelDown or MouseButton.WheelLeft or MouseButton.WheelRight)
|
||||
return;
|
||||
if (button.ButtonIndex == MouseButton.Middle)
|
||||
{
|
||||
StartOrEndPan(button);
|
||||
AcceptEvent();
|
||||
return;
|
||||
}
|
||||
|
||||
var cell = CellAt(button.Position);
|
||||
if (!button.Pressed)
|
||||
@@ -258,6 +316,15 @@ namespace Cthangover.Tools.RpgEditor
|
||||
return;
|
||||
}
|
||||
|
||||
if (Placing && (button.ButtonIndex == MouseButton.Left || button.ButtonIndex == MouseButton.Right))
|
||||
{
|
||||
Placing = false;
|
||||
var chosen = button.ButtonIndex == MouseButton.Left && Document.Grid.InBounds(cell.X, cell.Y);
|
||||
CellChosen?.Invoke(chosen ? cell : null);
|
||||
AcceptEvent();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ActiveLayer == RpgEditLayer.Events)
|
||||
PressOnEvents(button, cell);
|
||||
else if (button.ButtonIndex == MouseButton.Left || button.ButtonIndex == MouseButton.Right)
|
||||
@@ -343,6 +410,36 @@ namespace Cthangover.Tools.RpgEditor
|
||||
QueueRedraw();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Middle button down starts panning the parent ScrollContainer, up ends
|
||||
/// it. The scroll offset is anchored to the press point rather than
|
||||
/// accumulated from deltas, so hitting a scroll edge builds no drift.
|
||||
/// </summary>
|
||||
private void StartOrEndPan(InputEventMouseButton button)
|
||||
{
|
||||
var scroll = GetParentOrNull<ScrollContainer>();
|
||||
panning = button.Pressed && scroll != null;
|
||||
MouseDefaultCursorShape = panning ? CursorShape.Drag : CursorShape.Arrow;
|
||||
if (!panning)
|
||||
return;
|
||||
panStartMouse = button.GlobalPosition;
|
||||
panStartScroll = new Vector2(scroll.ScrollHorizontal, scroll.ScrollVertical);
|
||||
}
|
||||
|
||||
private void Pan(InputEventMouseMotion motion)
|
||||
{
|
||||
var scroll = GetParentOrNull<ScrollContainer>();
|
||||
if (scroll == null || (motion.ButtonMask & MouseButtonMask.Middle) == 0)
|
||||
{
|
||||
panning = false;
|
||||
MouseDefaultCursorShape = CursorShape.Arrow;
|
||||
return;
|
||||
}
|
||||
var delta = motion.GlobalPosition - panStartMouse;
|
||||
scroll.ScrollHorizontal = Mathf.RoundToInt(panStartScroll.X - delta.X);
|
||||
scroll.ScrollVertical = Mathf.RoundToInt(panStartScroll.Y - delta.Y);
|
||||
}
|
||||
|
||||
private void OnMouseExited()
|
||||
{
|
||||
hover = null;
|
||||
|
||||
+118
-6
@@ -19,8 +19,11 @@ namespace Cthangover.Tools.RpgEditor
|
||||
/// <summary>
|
||||
/// Editor of RPG tile maps (<c>maps/*.json</c>, kind <c>rpg_map</c>).
|
||||
/// Layout: toolbar (file, save, layer, brush, grid, zoom, undo/redo, play),
|
||||
/// the tile palette on the left, the map in the middle, the map's and the
|
||||
/// selected event's properties on the right.
|
||||
/// the tile palette, the game's characters and items as tabs on the left, the
|
||||
/// map in the middle, the map's and the selected event's properties on the right.
|
||||
/// A character is put on the map as an NPC or an enemy, an item as a pickup:
|
||||
/// pick it, press the button, click a cell (<see cref="RpgMapDocument.AddCharacter"/>,
|
||||
/// <see cref="RpgMapDocument.AddItem"/>).
|
||||
/// <para/>
|
||||
/// Layers are the game's: back, main, front, the collision layer (codes
|
||||
/// over the tiles' own solidity) and events. Every edit is undoable
|
||||
@@ -31,6 +34,10 @@ namespace Cthangover.Tools.RpgEditor
|
||||
/// (<see cref="SceneManager.ReloadContent"/>), so "Play" works on a new map
|
||||
/// without a mod reload.
|
||||
/// <para/>
|
||||
/// Saving writes the map file and the mod's events file of the map,
|
||||
/// <c>map_events/<id>/events.json</c> (<see cref="RpgMapDocument"/> explains
|
||||
/// own and locked events).
|
||||
/// <para/>
|
||||
/// After a save the map is put into the running game's map cache, and a
|
||||
/// world showing it is remounted, so the edit is visible at once; "Play"
|
||||
/// saves and moves the player onto the map at its spawn.
|
||||
@@ -44,7 +51,12 @@ namespace Cthangover.Tools.RpgEditor
|
||||
private readonly Dictionary<RpgEditTool, Button> toolButtons = new();
|
||||
|
||||
private ModFileSelector fileSelector;
|
||||
private TabContainer sideTabs;
|
||||
private RpgTilePalette palette;
|
||||
private SpinBox itemCount;
|
||||
private Func<Vector2I, RpgMapEvent> placeAt;
|
||||
private string placedKey;
|
||||
private string placedName;
|
||||
private RpgMapEditorCanvas canvas;
|
||||
private RpgMapPropertiesPanel mapPanel;
|
||||
private RpgEventPropertiesPanel eventPanel;
|
||||
@@ -78,13 +90,38 @@ namespace Cthangover.Tools.RpgEditor
|
||||
var main = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, SizeFlagsVertical = Control.SizeFlags.ExpandFill };
|
||||
outer.AddChild(main);
|
||||
|
||||
var leftColumn = new VBoxContainer();
|
||||
sideTabs = new TabContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
|
||||
leftColumn.AddChild(sideTabs);
|
||||
|
||||
var paletteColumn = new VBoxContainer();
|
||||
paletteColumn.AddChild(ToolUi.Section("tools/rpg_editor/section_palette"));
|
||||
palette = new RpgTilePalette();
|
||||
palette.TileSelected += OnPaletteTile;
|
||||
palette.CollisionSelected += OnPaletteCollision;
|
||||
paletteColumn.AddChild(palette);
|
||||
main.AddChild(CreateSidebar(paletteColumn, 400));
|
||||
sideTabs.AddChild(paletteColumn);
|
||||
sideTabs.SetTabTitle(0, ToolUi.Tr("tools/rpg_editor/tab_tiles"));
|
||||
|
||||
var characters = new RpgEntityPicker("tools/rpg_editor/character_search", "tools/rpg_editor/characters_hint") { Name = "Characters" };
|
||||
characters.Source = LoadCharacters;
|
||||
characters.AddAction("tools/rpg_editor/place_npc", "tools/rpg_editor/place_npc_tip", PlaceNpc);
|
||||
characters.AddAction("tools/rpg_editor/place_enemy", "tools/rpg_editor/place_enemy_tip", PlaceEnemy);
|
||||
sideTabs.AddChild(characters);
|
||||
sideTabs.SetTabTitle(1, ToolUi.Tr("tools/rpg_editor/tab_characters"));
|
||||
|
||||
var items = new RpgEntityPicker("tools/rpg_editor/item_search", "tools/rpg_editor/items_hint") { Name = "Items" };
|
||||
items.Source = LoadItems;
|
||||
itemCount = ToolUi.Number(1, 999, 1, null);
|
||||
itemCount.Value = 1;
|
||||
itemCount.TooltipText = ToolUi.Tr("tools/rpg_editor/item_count_tip");
|
||||
items.AddOption(new Label { Text = ToolUi.Tr("tools/rpg_editor/item_count") });
|
||||
items.AddOption(itemCount);
|
||||
items.AddAction("tools/rpg_editor/place_item", "tools/rpg_editor/place_item_tip", PlaceItem);
|
||||
sideTabs.AddChild(items);
|
||||
sideTabs.SetTabTitle(2, ToolUi.Tr("tools/rpg_editor/tab_items"));
|
||||
sideTabs.TabChanged += OnSideTabChanged;
|
||||
main.AddChild(CreateSidebar(leftColumn, 400));
|
||||
|
||||
var center = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
|
||||
main.AddChild(center);
|
||||
@@ -94,6 +131,7 @@ namespace Cthangover.Tools.RpgEditor
|
||||
canvas.Changed += OnDocumentChanged;
|
||||
canvas.EventSelected += OnEventSelected;
|
||||
canvas.TilePicked += OnTilePicked;
|
||||
canvas.CellChosen += OnCellChosen;
|
||||
center.AddChild(Scroll(canvas));
|
||||
|
||||
var properties = new VBoxContainer();
|
||||
@@ -206,7 +244,7 @@ namespace Cthangover.Tools.RpgEditor
|
||||
{
|
||||
var text = ModFileService.ReadText(mod, file);
|
||||
isNew = string.IsNullOrEmpty(text);
|
||||
document = isNew ? CreateDocument(file) : RpgMapDocument.Load(text);
|
||||
document = isNew ? CreateDocument(file) : LoadDocument(mod, text);
|
||||
SetStatus(string.Format(ToolUi.Tr(isNew ? "tools/common/new_file_status" : "tools/common/loaded"), mod + "/" + file));
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -221,6 +259,23 @@ namespace Cthangover.Tools.RpgEditor
|
||||
BindDocument();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The map with the editor's events file of the same mod; every other event
|
||||
/// the game shows on the map is locked. An own event hides a locked one of the
|
||||
/// same id (another mod replacing it), so the author sees and edits their own.
|
||||
/// </summary>
|
||||
private static RpgMapDocument LoadDocument(string mod, string text)
|
||||
{
|
||||
var id = RpgMapDocument.Load(text).Map.ID;
|
||||
var eventsPath = RpgMapDocument.EventsPath(id);
|
||||
var document = RpgMapDocument.Load(text, ModFileService.ReadText(mod, eventsPath));
|
||||
var own = new HashSet<string>(document.Events.Select(e => e.Id), StringComparer.OrdinalIgnoreCase);
|
||||
document.SetLockedEvents(RpgMapEvents.Of(id)
|
||||
.Where(e => !(e.ModId == mod && e.File == eventsPath) && !own.Contains(e.Id))
|
||||
.ToList());
|
||||
return document;
|
||||
}
|
||||
|
||||
private static RpgMapDocument CreateDocument(string file)
|
||||
{
|
||||
var tileset = Entities.Collect<RpgTileset>().Keys.OrderBy(k => k).FirstOrDefault();
|
||||
@@ -248,11 +303,15 @@ namespace Cthangover.Tools.RpgEditor
|
||||
}
|
||||
|
||||
var json = document.ToJson();
|
||||
if (!ModFileService.WriteText(modId, path, json))
|
||||
var eventsPath = RpgMapDocument.EventsPath(document.Map.ID);
|
||||
var writeEvents = document.Events.Count > 0 || !string.IsNullOrEmpty(ModFileService.ReadText(modId, eventsPath));
|
||||
if (!ModFileService.WriteText(modId, path, json)
|
||||
|| (writeEvents && !ModFileService.WriteText(modId, eventsPath, document.EventsToJson())))
|
||||
{
|
||||
SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error);
|
||||
return false;
|
||||
}
|
||||
RpgMapEvents.Invalidate();
|
||||
|
||||
var sceneNote = isNew ? EnsureScene() : null;
|
||||
if (sceneNote != null)
|
||||
@@ -342,7 +401,7 @@ namespace Cthangover.Tools.RpgEditor
|
||||
return;
|
||||
// The document holds a new map object now: find the selection again by id.
|
||||
var selectedId = canvas.SelectedEvent?.Id;
|
||||
canvas.SelectedEvent = document.Map.Events.FirstOrDefault(e => e.Id == selectedId);
|
||||
canvas.SelectedEvent = document.Events.FirstOrDefault(e => e.Id == selectedId);
|
||||
SetDirty();
|
||||
BindDocument();
|
||||
}
|
||||
@@ -379,6 +438,57 @@ namespace Cthangover.Tools.RpgEditor
|
||||
|
||||
private void OnPaletteCollision(char code) => canvas.SelectedCollision = code;
|
||||
|
||||
private void OnSideTabChanged(long tab)
|
||||
{
|
||||
if (sideTabs.GetCurrentTabControl() is RpgEntityPicker picker)
|
||||
picker.Reload();
|
||||
}
|
||||
|
||||
private List<RpgPickerEntry> LoadCharacters() => RpgCharacterCatalog.Load(textures);
|
||||
|
||||
private List<RpgPickerEntry> LoadItems() => RpgItemCatalog.Load(textures);
|
||||
|
||||
private void PlaceNpc(RpgPickerEntry entry)
|
||||
=> BeginPlacing(entry, "tools/rpg_editor/placed_npc", cell => document.AddCharacter(cell.X, cell.Y, entry.Id, entry.Look, false));
|
||||
|
||||
private void PlaceEnemy(RpgPickerEntry entry)
|
||||
=> BeginPlacing(entry, "tools/rpg_editor/placed_enemy", cell => document.AddCharacter(cell.X, cell.Y, entry.Id, entry.Look, true));
|
||||
|
||||
private void PlaceItem(RpgPickerEntry entry)
|
||||
{
|
||||
var count = (int)itemCount.Value;
|
||||
var line = string.Format(ToolUi.Tr("tools/rpg_editor/item_found_line"), entry.Name, count);
|
||||
BeginPlacing(entry, "tools/rpg_editor/placed_item", cell => document.AddItem(cell.X, cell.Y, entry.Id, entry.Look, count, line));
|
||||
}
|
||||
|
||||
/// <summary>Something is to be placed: switch to the events layer and wait for a cell; <paramref name="place"/> makes the event.</summary>
|
||||
private void BeginPlacing(RpgPickerEntry entry, string doneKey, Func<Vector2I, RpgMapEvent> place)
|
||||
{
|
||||
if (document == null)
|
||||
return;
|
||||
placeAt = place;
|
||||
placedKey = doneKey;
|
||||
placedName = entry.Name;
|
||||
SelectLayer(RpgEditLayer.Events);
|
||||
canvas.Placing = true;
|
||||
SetStatus(string.Format(ToolUi.Tr("tools/rpg_editor/place_status"), entry.Name));
|
||||
}
|
||||
|
||||
private void OnCellChosen(Vector2I? cell)
|
||||
{
|
||||
if (cell == null || placeAt == null || document == null)
|
||||
{
|
||||
SetStatus(ToolUi.Tr("tools/rpg_editor/place_cancelled"));
|
||||
return;
|
||||
}
|
||||
RecordUndo();
|
||||
var mapEvent = placeAt(cell.Value);
|
||||
canvas.SelectedEvent = mapEvent;
|
||||
eventPanel.Bind(mapEvent);
|
||||
OnDocumentChanged();
|
||||
SetStatus(string.Format(ToolUi.Tr(placedKey), placedName, mapEvent.Id), ToolStatusKind.Ok);
|
||||
}
|
||||
|
||||
private void OnTilePicked(int index)
|
||||
{
|
||||
canvas.SelectedTile = index;
|
||||
@@ -388,6 +498,8 @@ namespace Cthangover.Tools.RpgEditor
|
||||
private void SelectLayer(RpgEditLayer layer)
|
||||
{
|
||||
canvas.ActiveLayer = layer;
|
||||
if (layer != RpgEditLayer.Events)
|
||||
canvas.Placing = false;
|
||||
foreach (var pair in layerButtons)
|
||||
pair.Value.SetPressedNoSignal(pair.Key == layer);
|
||||
palette.SetCollisionMode(layer == RpgEditLayer.Collision);
|
||||
|
||||
@@ -2,7 +2,7 @@ using Cthangover.Mods.Rpg;
|
||||
|
||||
namespace Cthangover.Tools.RpgEditor
|
||||
{
|
||||
/// <summary>One undo step of <see cref="RpgMapDocument"/>: the cells and the rest of the map as JSON.</summary>
|
||||
/// <summary>One undo step of <see cref="RpgMapDocument"/>: the cells, the rest of the map and the own events as JSON.</summary>
|
||||
public class RpgMapSnapshot
|
||||
{
|
||||
/// <summary>Copy of the cells.</summary>
|
||||
@@ -11,11 +11,15 @@ namespace Cthangover.Tools.RpgEditor
|
||||
/// <summary>The map without its cells (rows may be stale), serialized.</summary>
|
||||
public string MapJson { get; }
|
||||
|
||||
/// <summary>The document's own events, serialized.</summary>
|
||||
public string EventsJson { get; }
|
||||
|
||||
/// <summary>Creates a step.</summary>
|
||||
public RpgMapSnapshot(RpgMapGrid grid, string mapJson)
|
||||
public RpgMapSnapshot(RpgMapGrid grid, string mapJson, string eventsJson)
|
||||
{
|
||||
Grid = grid;
|
||||
MapJson = mapJson;
|
||||
EventsJson = eventsJson;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Tools.RpgEditor
|
||||
{
|
||||
/// <summary>One row of an <see cref="RpgEntityPicker"/>: a character or an item of the game.</summary>
|
||||
public class RpgPickerEntry
|
||||
{
|
||||
/// <summary>Entity id (what <c>rpg.battle enemies=</c> or <c>item.add id=</c> takes).</summary>
|
||||
public string Id { get; init; }
|
||||
|
||||
/// <summary>Display name in the current language (the id when the entity has none).</summary>
|
||||
public string Name { get; init; }
|
||||
|
||||
/// <summary>How it looks on the map: a charset path for a character, an item picture path for an item; null for none.</summary>
|
||||
public string Look { get; init; }
|
||||
|
||||
/// <summary>Picture of the row, or null.</summary>
|
||||
public Texture2D Icon { get; init; }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user