Compare commits
5 commits
map-pipeli
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6be9e2e39a | |||
| ef5fca2441 | |||
| ce56b6d829 | |||
| f4a0dc57c3 | |||
| dc9184075b |
16 changed files with 418 additions and 222 deletions
|
|
@ -14,6 +14,5 @@ MonoBehaviour:
|
|||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbilities.BlinkAbilityDefinition
|
||||
DisplayName: Blink
|
||||
Description: Enemies occasionally teleport a short distance ahead.
|
||||
IntervalSeconds: 4
|
||||
BlinkWaypoints: 2
|
||||
StartJitterSeconds: 1.5
|
||||
IntervalSeconds: 8
|
||||
BlinkDistanceMeters: 4
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ MonoBehaviour:
|
|||
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
|
||||
GlobalObjectIdHash: 3037368120
|
||||
GlobalObjectIdHash: 3715663997
|
||||
InScenePlacedSourceGlobalObjectIdHash: 0
|
||||
DeferredDespawnTick: 0
|
||||
Ownership: 1
|
||||
|
|
@ -125,7 +125,7 @@ MonoBehaviour:
|
|||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth
|
||||
ShowTopMostFoldoutHeaderGroup: 1
|
||||
definition: {fileID: 0}
|
||||
definition: {fileID: 11400000, guid: edc686813aef43743bb15d356b511dff, type: 2}
|
||||
deathAnimator: {fileID: 8642767442976997525}
|
||||
deathAnimationDuration: 1.5
|
||||
sinkDuration: 1.5
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ MonoBehaviour:
|
|||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth
|
||||
ShowTopMostFoldoutHeaderGroup: 1
|
||||
definition: {fileID: 0}
|
||||
definition: {fileID: 11400000, guid: fe31ccfc7a429b24fb8f57279ba96c95, type: 2}
|
||||
deathAnimator: {fileID: 7317567842465160949}
|
||||
deathAnimationDuration: 1.5
|
||||
sinkDuration: 1.5
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ MonoBehaviour:
|
|||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth
|
||||
ShowTopMostFoldoutHeaderGroup: 1
|
||||
definition: {fileID: 0}
|
||||
definition: {fileID: 11400000, guid: 21c69bd4b2c55cd4fa9cd9b551c6e3fe, type: 2}
|
||||
deathAnimator: {fileID: 6910253555362952583}
|
||||
deathAnimationDuration: 1.5
|
||||
sinkDuration: 1.5
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ MonoBehaviour:
|
|||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth
|
||||
ShowTopMostFoldoutHeaderGroup: 1
|
||||
definition: {fileID: 0}
|
||||
definition: {fileID: 11400000, guid: 52d9af0c680bca64a86d66fce2a73668, type: 2}
|
||||
deathAnimator: {fileID: 5079596453572633521}
|
||||
deathAnimationDuration: 1.5
|
||||
sinkDuration: 1.5
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ MonoBehaviour:
|
|||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth
|
||||
ShowTopMostFoldoutHeaderGroup: 1
|
||||
definition: {fileID: 0}
|
||||
definition: {fileID: 11400000, guid: 4b981d8b44098894197507508f655072, type: 2}
|
||||
deathAnimator: {fileID: 162562342023078655}
|
||||
deathAnimationDuration: 1.5
|
||||
sinkDuration: 1.5
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ MonoBehaviour:
|
|||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth
|
||||
ShowTopMostFoldoutHeaderGroup: 1
|
||||
definition: {fileID: 0}
|
||||
definition: {fileID: 11400000, guid: bed87a08deeb7e048ba06c8268b0f959, type: 2}
|
||||
deathAnimator: {fileID: 6938810086909213800}
|
||||
deathAnimationDuration: 1.5
|
||||
sinkDuration: 1.5
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ MonoBehaviour:
|
|||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth
|
||||
ShowTopMostFoldoutHeaderGroup: 1
|
||||
definition: {fileID: 0}
|
||||
definition: {fileID: 11400000, guid: 77f4545654c5b7b41b2760a580da6068, type: 2}
|
||||
deathAnimator: {fileID: 2830498994423139172}
|
||||
deathAnimationDuration: 1.5
|
||||
sinkDuration: 1.5
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ MonoBehaviour:
|
|||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth
|
||||
ShowTopMostFoldoutHeaderGroup: 1
|
||||
definition: {fileID: 0}
|
||||
definition: {fileID: 11400000, guid: 097d7ba78591cd443a06d637e0822a46, type: 2}
|
||||
deathAnimator: {fileID: 471366370285235598}
|
||||
deathAnimationDuration: 1.5
|
||||
sinkDuration: 1.5
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ MonoBehaviour:
|
|||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyHealth
|
||||
ShowTopMostFoldoutHeaderGroup: 1
|
||||
definition: {fileID: 0}
|
||||
definition: {fileID: 11400000, guid: 1d9809612c00ed049848a955613a4619, type: 2}
|
||||
deathAnimator: {fileID: 3345626123719039665}
|
||||
deathAnimationDuration: 1.5
|
||||
sinkDuration: 1.5
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ using System.Collections.Generic;
|
|||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
using TD.Gameplay.Draft;
|
||||
using TD.Gameplay.EnemyUpgrades;
|
||||
using TD.Gameplay.Waves;
|
||||
|
||||
namespace TD.Dev
|
||||
{
|
||||
|
|
@ -74,5 +76,42 @@ namespace TD.Dev
|
|||
Debug.LogWarning($"[DebugCommandRelay] '{option.DisplayName}' could not be applied " +
|
||||
$"to client {OwnerClientId}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Force-apply the <see cref="EnemyUpgradeOption"/> at <paramref name="optionId"/> (its
|
||||
/// <see cref="EnemyUpgradePool"/> index) to the run's current wave slot — the same
|
||||
/// recording <c>WaveVote.ServerResolve</c> does for a vote winner, skipping the vote
|
||||
/// entirely. Not player-scoped: the effect lands on the run's current wave slot rather
|
||||
/// than the caller, so any connected client's relay can trigger it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Takes effect the next time this wave slot's enemies spawn, not retroactively on
|
||||
/// enemies already alive — see <see cref="EnemyUpgradeOption"/>'s remarks on why
|
||||
/// applying is "record on the slot," not a direct mutation.
|
||||
/// </remarks>
|
||||
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
|
||||
public void DebugGrantEnemyUpgradeRpc(int optionId)
|
||||
{
|
||||
var pool = EnemyUpgradePool.Instance;
|
||||
var option = pool != null ? pool.Get(optionId) : null;
|
||||
if (option == null)
|
||||
{
|
||||
Debug.LogWarning($"[DebugCommandRelay] Grant requested for unknown enemy upgrade id {optionId}.");
|
||||
return;
|
||||
}
|
||||
|
||||
var run = RunState.Instance;
|
||||
if (run == null)
|
||||
{
|
||||
Debug.LogWarning("[DebugCommandRelay] No RunState in this scene.");
|
||||
return;
|
||||
}
|
||||
|
||||
int slot = run.CurrentUpgradeSlot;
|
||||
run.ServerAddUpgrade(slot, optionId);
|
||||
option.ServerOnApplied(run, slot);
|
||||
|
||||
Debug.Log($"[DebugCommandRelay] Granted '{option.DisplayName}' to wave slot {slot}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ using UnityEngine.InputSystem;
|
|||
using UnityEngine.UIElements;
|
||||
using TD.UI;
|
||||
using TD.Gameplay.Draft;
|
||||
using TD.Gameplay.EnemyUpgrades;
|
||||
|
||||
namespace TD.Dev
|
||||
{
|
||||
|
|
@ -49,13 +50,7 @@ namespace TD.Dev
|
|||
"System (UnityEngine.InputSystem.Key). Backquote is the physical ` / ~ key.")]
|
||||
[SerializeField] private Key toggleKey = Key.Backquote;
|
||||
|
||||
// Beyond this many candidates the list is truncated with a "+N more" row so a
|
||||
// large DraftPool can't grow the console bar off the top of the screen.
|
||||
private const int MaxVisibleSuggestions = 12;
|
||||
|
||||
private VisualElement consoleContainer;
|
||||
private TextField commandInput;
|
||||
private VisualElement suggestionList;
|
||||
private DebugConsoleUI ui;
|
||||
private bool consoleOpen;
|
||||
|
||||
// Frame on which the console was opened or closed. The toggle key and Enter
|
||||
|
|
@ -98,123 +93,23 @@ namespace TD.Dev
|
|||
return;
|
||||
}
|
||||
|
||||
BuildConsoleUi(root);
|
||||
}
|
||||
|
||||
private void BuildConsoleUi(VisualElement uiRoot)
|
||||
{
|
||||
// Top-anchored bar, closed by default. Distinct position from the HUD's
|
||||
// bottom-left chat panel so the two never overlap.
|
||||
consoleContainer = new VisualElement();
|
||||
consoleContainer.pickingMode = PickingMode.Ignore;
|
||||
consoleContainer.style.position = Position.Absolute;
|
||||
consoleContainer.style.left = 0;
|
||||
consoleContainer.style.right = 0;
|
||||
consoleContainer.style.top = 0;
|
||||
consoleContainer.style.paddingTop = 8;
|
||||
consoleContainer.style.paddingBottom = 8;
|
||||
consoleContainer.style.paddingLeft = 12;
|
||||
consoleContainer.style.paddingRight = 12;
|
||||
// Fully opaque: the console is a text-entry overlay that has to stay readable
|
||||
// over the level, the HUD, and whatever else is on screen.
|
||||
consoleContainer.style.backgroundColor = new Color(0f, 0f, 0f, 1f);
|
||||
consoleContainer.style.display = DisplayStyle.None;
|
||||
|
||||
commandInput = new TextField();
|
||||
commandInput.style.minHeight = 28;
|
||||
commandInput.style.width = Length.Percent(100);
|
||||
commandInput.maxLength = 200;
|
||||
commandInput.isDelayed = false;
|
||||
StyleConsoleInput(commandInput);
|
||||
ui = new DebugConsoleUI(root);
|
||||
|
||||
// Focus tracking — contributes to the same flag HUDController's chat
|
||||
// input sets, so camera/builder/spell input all stay gated correctly
|
||||
// regardless of which text field currently has focus.
|
||||
commandInput.RegisterCallback<FocusInEvent>(_ => HUDController.IsTextInputActive = true);
|
||||
commandInput.RegisterCallback<FocusOutEvent>(_ => HUDController.IsTextInputActive = false);
|
||||
ui.CommandInput.RegisterCallback<FocusInEvent>(_ => HUDController.IsTextInputActive = true);
|
||||
ui.CommandInput.RegisterCallback<FocusOutEvent>(_ => HUDController.IsTextInputActive = false);
|
||||
|
||||
commandInput.RegisterValueChangedCallback(_ => RefreshSuggestions());
|
||||
ui.CommandInput.RegisterValueChangedCallback(_ => RefreshSuggestions());
|
||||
|
||||
// Tab and the arrows can't be polled from Keyboard.current the way Enter and
|
||||
// Escape are (below): UI Toolkit would still insert a tab character and move
|
||||
// the focus ring before Update runs. Intercept them on the field in the
|
||||
// trickle-down phase, before the text engine and the focus ring see them.
|
||||
commandInput.RegisterCallback<KeyDownEvent>(OnConsoleKeyDown, TrickleDown.TrickleDown);
|
||||
commandInput.RegisterCallback<NavigationMoveEvent>(OnConsoleNavigationMove,
|
||||
TrickleDown.TrickleDown);
|
||||
|
||||
suggestionList = new VisualElement();
|
||||
suggestionList.pickingMode = PickingMode.Ignore;
|
||||
suggestionList.style.flexDirection = FlexDirection.Column;
|
||||
suggestionList.style.marginTop = 4;
|
||||
suggestionList.style.display = DisplayStyle.None;
|
||||
|
||||
consoleContainer.Add(commandInput);
|
||||
consoleContainer.Add(suggestionList);
|
||||
uiRoot.Add(consoleContainer);
|
||||
}
|
||||
|
||||
// Mirrors HUDController.StyleChatInputDark — dark background, white text,
|
||||
// a thin border, and inner padding so ascenders/descenders don't clip.
|
||||
// Green border (vs. chat's neutral gray) as a quick visual "this is the
|
||||
// debug console, not chat" cue.
|
||||
private static void StyleConsoleInput(TextField field)
|
||||
{
|
||||
var darkBg = new Color(0.05f, 0.05f, 0.05f, 1f);
|
||||
var borderClr = new Color(0.35f, 0.75f, 0.35f);
|
||||
|
||||
field.style.backgroundColor = darkBg;
|
||||
field.style.color = Color.white;
|
||||
field.style.borderTopWidth = field.style.borderBottomWidth =
|
||||
field.style.borderLeftWidth = field.style.borderRightWidth = 1;
|
||||
field.style.borderTopColor = field.style.borderBottomColor =
|
||||
field.style.borderLeftColor = field.style.borderRightColor = borderClr;
|
||||
|
||||
var inner = field.Q("unity-text-input");
|
||||
if (inner != null)
|
||||
{
|
||||
inner.style.backgroundColor = darkBg;
|
||||
inner.style.color = Color.white;
|
||||
inner.style.paddingTop = 4;
|
||||
inner.style.paddingBottom = 4;
|
||||
inner.style.paddingLeft = 6;
|
||||
inner.style.paddingRight = 6;
|
||||
}
|
||||
}
|
||||
|
||||
// One autocomplete row: the candidate token plus an optional dim hint. Rows are
|
||||
// pickingMode Ignore and keyboard-driven only — nothing here is clickable, which
|
||||
// avoids the recursive picking-mode problem documented in HUDController's chat
|
||||
// panel (clicks falling through to the game world via a ScrollView's internal
|
||||
// wrapper element).
|
||||
private static VisualElement BuildSuggestionRow(string text, string hint, bool selected)
|
||||
{
|
||||
var row = new VisualElement();
|
||||
row.pickingMode = PickingMode.Ignore;
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.minHeight = 20;
|
||||
row.style.paddingLeft = 6;
|
||||
row.style.paddingRight = 6;
|
||||
row.style.backgroundColor = selected
|
||||
? new Color(0.16f, 0.38f, 0.16f, 1f) // matches the green console cue
|
||||
: new Color(0.05f, 0.05f, 0.05f, 1f);
|
||||
|
||||
var label = new Label(text);
|
||||
label.pickingMode = PickingMode.Ignore;
|
||||
label.style.color = Color.white;
|
||||
label.style.unityFontStyleAndWeight = selected ? FontStyle.Bold : FontStyle.Normal;
|
||||
row.Add(label);
|
||||
|
||||
if (!string.IsNullOrEmpty(hint))
|
||||
{
|
||||
var hintLabel = new Label(hint);
|
||||
hintLabel.pickingMode = PickingMode.Ignore;
|
||||
hintLabel.style.color = new Color(0.6f, 0.6f, 0.6f);
|
||||
hintLabel.style.marginLeft = 12;
|
||||
row.Add(hintLabel);
|
||||
}
|
||||
|
||||
return row;
|
||||
ui.CommandInput.RegisterCallback<KeyDownEvent>(OnConsoleKeyDown, TrickleDown.TrickleDown);
|
||||
ui.CommandInput.RegisterCallback<NavigationMoveEvent>(OnConsoleNavigationMove,
|
||||
TrickleDown.TrickleDown);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
|
|
@ -279,15 +174,14 @@ namespace TD.Dev
|
|||
private void Swallow(EventBase evt)
|
||||
{
|
||||
evt.StopImmediatePropagation();
|
||||
commandInput?.panel?.focusController?.IgnoreEvent(evt);
|
||||
ui.CommandInput?.panel?.focusController?.IgnoreEvent(evt);
|
||||
}
|
||||
|
||||
private void OpenConsole()
|
||||
{
|
||||
if (commandInput == null) return;
|
||||
consoleContainer.style.display = DisplayStyle.Flex;
|
||||
consoleContainer.pickingMode = PickingMode.Position;
|
||||
commandInput.SetValueWithoutNotify(string.Empty);
|
||||
if (ui.CommandInput == null) return;
|
||||
ui.Show();
|
||||
ui.CommandInput.SetValueWithoutNotify(string.Empty);
|
||||
RefreshSuggestions();
|
||||
|
||||
// Suppress the toggle key and Enter for this frame and the next so the
|
||||
|
|
@ -302,33 +196,25 @@ namespace TD.Dev
|
|||
private IEnumerator FocusNextFrame()
|
||||
{
|
||||
yield return null;
|
||||
if (consoleOpen && commandInput != null)
|
||||
if (consoleOpen && ui.CommandInput != null)
|
||||
{
|
||||
commandInput.Focus();
|
||||
commandInput.SelectAll();
|
||||
ui.CommandInput.Focus();
|
||||
ui.CommandInput.SelectAll();
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseConsole()
|
||||
{
|
||||
if (commandInput != null)
|
||||
if (ui.CommandInput != null)
|
||||
{
|
||||
commandInput.SetValueWithoutNotify(string.Empty);
|
||||
commandInput.Blur();
|
||||
}
|
||||
if (consoleContainer != null)
|
||||
{
|
||||
consoleContainer.style.display = DisplayStyle.None;
|
||||
consoleContainer.pickingMode = PickingMode.Ignore;
|
||||
ui.CommandInput.SetValueWithoutNotify(string.Empty);
|
||||
ui.CommandInput.Blur();
|
||||
}
|
||||
ui.Hide();
|
||||
|
||||
suggestions.Clear();
|
||||
selectedIndex = 0;
|
||||
if (suggestionList != null)
|
||||
{
|
||||
suggestionList.Clear();
|
||||
suggestionList.style.display = DisplayStyle.None;
|
||||
}
|
||||
ui.ClearSuggestions();
|
||||
|
||||
consoleOpen = false;
|
||||
toggleSuppressFrame = Time.frameCount + 1;
|
||||
|
|
@ -346,7 +232,7 @@ namespace TD.Dev
|
|||
|
||||
private void SubmitCommand()
|
||||
{
|
||||
string text = commandInput?.value ?? string.Empty;
|
||||
string text = ui.CommandInput?.value ?? string.Empty;
|
||||
|
||||
// Enter doubles as "accept the highlighted suggestion" while the typed
|
||||
// command isn't runnable yet, so you can drive the whole thing with Enter
|
||||
|
|
@ -377,11 +263,11 @@ namespace TD.Dev
|
|||
public string Hint; // dim one-liner shown beside the token
|
||||
public Func<List<string>> Values; // non-null => this level takes a final value
|
||||
public bool Executable; // a complete command can end here
|
||||
public Type OptionType; // "get": the DraftOption subclass to match
|
||||
public Action<string> Execute; // "Values" level: run the completed command
|
||||
public readonly List<CommandNode> Children = new List<CommandNode>();
|
||||
}
|
||||
|
||||
private const string UsageHint = "Try: get <type> <name>";
|
||||
private const string UsageHint = "Try: get <type> <name>, grant enemy <ability>";
|
||||
|
||||
// Maps the type token in "get <type> <name>" to the DraftOption subclass it
|
||||
// should match against. Add an entry here whenever a new DraftOption subclass
|
||||
|
|
@ -412,17 +298,34 @@ namespace TD.Dev
|
|||
|
||||
foreach (var pair in DraftTypeAliases)
|
||||
{
|
||||
string typeToken = pair.Key;
|
||||
var optionType = pair.Value;
|
||||
get.Children.Add(new CommandNode
|
||||
{
|
||||
Token = pair.Key,
|
||||
Token = typeToken,
|
||||
Hint = "<name>",
|
||||
OptionType = optionType,
|
||||
Executable = true,
|
||||
Values = () => DraftOptionNames(optionType),
|
||||
Execute = name => ExecuteGet(typeToken, optionType, name),
|
||||
});
|
||||
}
|
||||
|
||||
var grant = new CommandNode
|
||||
{
|
||||
Token = "grant",
|
||||
Hint = "grant a buff to the enemies",
|
||||
};
|
||||
root.Children.Add(grant);
|
||||
|
||||
grant.Children.Add(new CommandNode
|
||||
{
|
||||
Token = "enemy",
|
||||
Hint = "<ability>",
|
||||
Executable = true,
|
||||
Values = EnemyUpgradeNames,
|
||||
Execute = ExecuteGrantEnemyAbility,
|
||||
});
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
|
|
@ -454,6 +357,35 @@ namespace TD.Dev
|
|||
return names;
|
||||
}
|
||||
|
||||
// Display names of every pooled EnemyUpgradeOption, alphabetically. Mirrors
|
||||
// DraftOptionNames above — EnemyUpgradePool is the enemy-side equivalent of DraftPool,
|
||||
// and this is the same Count/Get surface ExecuteGrantEnemyAbility walks. Includes
|
||||
// non-ability cards too (e.g. the vote-meta "Double Dip") — DebugGrantEnemyUpgradeRpc
|
||||
// resolves through EnemyUpgradeOption.ServerOnApplied regardless of subclass, so
|
||||
// there's nothing this command can't actually grant.
|
||||
private static List<string> EnemyUpgradeNames()
|
||||
{
|
||||
var names = new List<string>();
|
||||
|
||||
var pool = EnemyUpgradePool.Instance;
|
||||
if (pool == null) return names;
|
||||
|
||||
for (int i = 0; i < pool.Count; i++)
|
||||
{
|
||||
var option = pool.Get(i);
|
||||
if (option == null) continue;
|
||||
|
||||
string name = option.DisplayName;
|
||||
if (string.IsNullOrWhiteSpace(name)) continue;
|
||||
if (names.Contains(name)) continue;
|
||||
|
||||
names.Add(name);
|
||||
}
|
||||
|
||||
names.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
return names;
|
||||
}
|
||||
|
||||
// Valid literal tokens at a level, for error messages ("Expected one of: ...").
|
||||
private static string ChildTokens(CommandNode node)
|
||||
{
|
||||
|
|
@ -640,7 +572,7 @@ namespace TD.Dev
|
|||
|
||||
private void RefreshSuggestions()
|
||||
{
|
||||
if (commandInput == null) return;
|
||||
if (ui.CommandInput == null) return;
|
||||
|
||||
// Keep the highlight on the same candidate when it survives the new filter,
|
||||
// so typing another character doesn't silently move what Enter would accept.
|
||||
|
|
@ -648,7 +580,7 @@ namespace TD.Dev
|
|||
? suggestions[selectedIndex].Text
|
||||
: null;
|
||||
|
||||
suggestions = ComputeSuggestions(commandInput.value, out fragmentStart);
|
||||
suggestions = ComputeSuggestions(ui.CommandInput.value, out fragmentStart);
|
||||
|
||||
selectedIndex = 0;
|
||||
if (previouslySelected != null)
|
||||
|
|
@ -662,38 +594,15 @@ namespace TD.Dev
|
|||
}
|
||||
}
|
||||
|
||||
RebuildSuggestionRows();
|
||||
ui.RenderSuggestions(ToSuggestionRows(suggestions), selectedIndex);
|
||||
}
|
||||
|
||||
private void RebuildSuggestionRows()
|
||||
private static List<DebugConsoleUI.SuggestionRow> ToSuggestionRows(List<Suggestion> suggestions)
|
||||
{
|
||||
if (suggestionList == null) return;
|
||||
|
||||
suggestionList.Clear();
|
||||
|
||||
if (suggestions.Count == 0)
|
||||
{
|
||||
suggestionList.style.display = DisplayStyle.None;
|
||||
return;
|
||||
}
|
||||
|
||||
suggestionList.style.display = DisplayStyle.Flex;
|
||||
|
||||
// Window the list around the highlight so arrowing past row 12 keeps the
|
||||
// selected row on screen instead of scrolling it out of the truncated view.
|
||||
int start = suggestions.Count > MaxVisibleSuggestions
|
||||
? Mathf.Clamp(selectedIndex - MaxVisibleSuggestions / 2, 0,
|
||||
suggestions.Count - MaxVisibleSuggestions)
|
||||
: 0;
|
||||
int end = Mathf.Min(start + MaxVisibleSuggestions, suggestions.Count);
|
||||
|
||||
for (int i = start; i < end; i++)
|
||||
suggestionList.Add(BuildSuggestionRow(suggestions[i].Text, suggestions[i].Hint,
|
||||
i == selectedIndex));
|
||||
|
||||
int hidden = suggestions.Count - (end - start);
|
||||
if (hidden > 0)
|
||||
suggestionList.Add(BuildSuggestionRow($"+{hidden} more", null, false));
|
||||
var rows = new List<DebugConsoleUI.SuggestionRow>(suggestions.Count);
|
||||
for (int i = 0; i < suggestions.Count; i++)
|
||||
rows.Add(new DebugConsoleUI.SuggestionRow(suggestions[i].Text, suggestions[i].Hint));
|
||||
return rows;
|
||||
}
|
||||
|
||||
private void MoveSelection(int delta)
|
||||
|
|
@ -703,16 +612,16 @@ namespace TD.Dev
|
|||
selectedIndex = (selectedIndex + delta) % suggestions.Count;
|
||||
if (selectedIndex < 0) selectedIndex += suggestions.Count;
|
||||
|
||||
RebuildSuggestionRows();
|
||||
ui.RenderSuggestions(ToSuggestionRows(suggestions), selectedIndex);
|
||||
}
|
||||
|
||||
private bool AcceptSelectedSuggestion()
|
||||
{
|
||||
if (commandInput == null) return false;
|
||||
if (ui.CommandInput == null) return false;
|
||||
if (selectedIndex < 0 || selectedIndex >= suggestions.Count) return false;
|
||||
|
||||
var suggestion = suggestions[selectedIndex];
|
||||
string text = commandInput.value ?? string.Empty;
|
||||
string text = ui.CommandInput.value ?? string.Empty;
|
||||
int start = Mathf.Clamp(fragmentStart, 0, text.Length);
|
||||
|
||||
string completed = text.Substring(0, start) + suggestion.Text +
|
||||
|
|
@ -721,8 +630,8 @@ namespace TD.Dev
|
|||
// SetValueWithoutNotify + an explicit refresh instead of assigning value:
|
||||
// one deterministic recompute, and the caret placement below can't race the
|
||||
// change callback.
|
||||
commandInput.SetValueWithoutNotify(completed);
|
||||
commandInput.SelectRange(completed.Length, completed.Length);
|
||||
ui.CommandInput.SetValueWithoutNotify(completed);
|
||||
ui.CommandInput.SelectRange(completed.Length, completed.Length);
|
||||
RefreshSuggestions();
|
||||
return true;
|
||||
}
|
||||
|
|
@ -757,14 +666,14 @@ namespace TD.Dev
|
|||
}
|
||||
|
||||
string nameToken = string.Join(" ", walkTokens.GetRange(i, walkTokens.Count - i));
|
||||
ExecuteGet(node.Token, node.OptionType, nameToken);
|
||||
node.Execute(nameToken);
|
||||
}
|
||||
|
||||
// "get <type> <name>": grants any DraftOption of the given type whose DisplayName
|
||||
// matches <name>, via the local player's DebugCommandRelay. Reuses DraftOption's
|
||||
// existing type-agnostic ServerApply dispatch instead of hardcoding a grant path
|
||||
// per option type.
|
||||
private void ExecuteGet(string typeToken, Type optionType, string nameToken)
|
||||
private static void ExecuteGet(string typeToken, Type optionType, string nameToken)
|
||||
{
|
||||
var pool = DraftPool.Instance;
|
||||
if (pool == null)
|
||||
|
|
@ -795,6 +704,41 @@ namespace TD.Dev
|
|||
Debug.LogWarning($"[DebugConsole] No {typeToken} option named \"{nameToken}\" found.");
|
||||
}
|
||||
|
||||
// "grant enemy <ability>": the same effect as the post-wave vote picking that ability
|
||||
// card for the current wave slot — records it via RunState.ServerAddUpgrade and runs
|
||||
// its ServerOnApplied, mirroring WaveVote.ServerResolve exactly (see DebugCommandRelay.
|
||||
// DebugGrantEnemyUpgradeRpc). Takes effect the next time this wave slot's enemies
|
||||
// spawn, not retroactively on enemies already alive.
|
||||
private static void ExecuteGrantEnemyAbility(string nameToken)
|
||||
{
|
||||
var pool = EnemyUpgradePool.Instance;
|
||||
if (pool == null)
|
||||
{
|
||||
Debug.LogWarning("[DebugConsole] No EnemyUpgradePool in this scene.");
|
||||
return;
|
||||
}
|
||||
|
||||
string normalizedTarget = Normalize(nameToken);
|
||||
for (int i = 0; i < pool.Count; i++)
|
||||
{
|
||||
var option = pool.Get(i);
|
||||
if (option == null || Normalize(option.DisplayName) != normalizedTarget) continue;
|
||||
|
||||
var relay = DebugCommandRelay.Local;
|
||||
if (relay == null)
|
||||
{
|
||||
Debug.LogWarning("[DebugConsole] No local DebugCommandRelay found — " +
|
||||
"is it on the Player prefab?");
|
||||
return;
|
||||
}
|
||||
|
||||
relay.DebugGrantEnemyUpgradeRpc(i);
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.LogWarning($"[DebugConsole] No enemy ability named \"{nameToken}\" found.");
|
||||
}
|
||||
|
||||
// DisplayName is player-facing draft card text (its spacing/casing is chosen for
|
||||
// presentation, not for typing), so command matching strips everything but letters
|
||||
// and digits before comparing — "Slow Area", "slow_area", and "SlowArea" all match
|
||||
|
|
|
|||
185
Assets/_Project/Scripts/Dev/DebugConsoleUI.cs
Normal file
185
Assets/_Project/Scripts/Dev/DebugConsoleUI.cs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
// Assets/_Project/Scripts/Dev/DebugConsoleUI.cs
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace TD.Dev
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds and draws <see cref="DebugConsole"/>'s VisualElement tree: the input bar and
|
||||
/// its autocomplete list. Owns presentation only — element construction, styling, and
|
||||
/// row rendering — no command grammar, keybinding, or suggestion-selection logic; that
|
||||
/// stays in <see cref="DebugConsole"/>, which drives this class through its public
|
||||
/// members and reads/writes <see cref="CommandInput"/> directly for focus and value.
|
||||
/// </summary>
|
||||
internal sealed class DebugConsoleUI
|
||||
{
|
||||
// Beyond this many candidates the list is truncated with a "+N more" row so a
|
||||
// large DraftPool can't grow the console bar off the top of the screen.
|
||||
private const int MaxVisibleSuggestions = 12;
|
||||
|
||||
/// <summary>One row of the autocomplete list: a candidate token plus an optional dim hint.</summary>
|
||||
public readonly struct SuggestionRow
|
||||
{
|
||||
public readonly string Text;
|
||||
public readonly string Hint;
|
||||
|
||||
public SuggestionRow(string text, string hint)
|
||||
{
|
||||
Text = text;
|
||||
Hint = hint;
|
||||
}
|
||||
}
|
||||
|
||||
public TextField CommandInput { get; }
|
||||
|
||||
private readonly VisualElement consoleContainer;
|
||||
private readonly VisualElement suggestionList;
|
||||
|
||||
public DebugConsoleUI(VisualElement uiRoot)
|
||||
{
|
||||
// Top-anchored bar, closed by default. Distinct position from the HUD's
|
||||
// bottom-left chat panel so the two never overlap.
|
||||
consoleContainer = new VisualElement();
|
||||
consoleContainer.pickingMode = PickingMode.Ignore;
|
||||
consoleContainer.style.position = Position.Absolute;
|
||||
consoleContainer.style.left = 0;
|
||||
consoleContainer.style.right = 0;
|
||||
consoleContainer.style.top = 0;
|
||||
consoleContainer.style.paddingTop = 8;
|
||||
consoleContainer.style.paddingBottom = 8;
|
||||
consoleContainer.style.paddingLeft = 12;
|
||||
consoleContainer.style.paddingRight = 12;
|
||||
// Fully opaque: the console is a text-entry overlay that has to stay readable
|
||||
// over the level, the HUD, and whatever else is on screen.
|
||||
consoleContainer.style.backgroundColor = new Color(0f, 0f, 0f, 1f);
|
||||
consoleContainer.style.display = DisplayStyle.None;
|
||||
|
||||
CommandInput = new TextField();
|
||||
CommandInput.style.minHeight = 28;
|
||||
CommandInput.style.width = Length.Percent(100);
|
||||
CommandInput.maxLength = 200;
|
||||
CommandInput.isDelayed = false;
|
||||
StyleConsoleInput(CommandInput);
|
||||
|
||||
suggestionList = new VisualElement();
|
||||
suggestionList.pickingMode = PickingMode.Ignore;
|
||||
suggestionList.style.flexDirection = FlexDirection.Column;
|
||||
suggestionList.style.marginTop = 4;
|
||||
suggestionList.style.display = DisplayStyle.None;
|
||||
|
||||
consoleContainer.Add(CommandInput);
|
||||
consoleContainer.Add(suggestionList);
|
||||
uiRoot.Add(consoleContainer);
|
||||
}
|
||||
|
||||
// Mirrors HUDController.StyleChatInputDark — dark background, white text,
|
||||
// a thin border, and inner padding so ascenders/descenders don't clip.
|
||||
// Green border (vs. chat's neutral gray) as a quick visual "this is the
|
||||
// debug console, not chat" cue.
|
||||
private static void StyleConsoleInput(TextField field)
|
||||
{
|
||||
var darkBg = new Color(0.05f, 0.05f, 0.05f, 1f);
|
||||
var borderClr = new Color(0.35f, 0.75f, 0.35f);
|
||||
|
||||
field.style.backgroundColor = darkBg;
|
||||
field.style.color = Color.white;
|
||||
field.style.borderTopWidth = field.style.borderBottomWidth =
|
||||
field.style.borderLeftWidth = field.style.borderRightWidth = 1;
|
||||
field.style.borderTopColor = field.style.borderBottomColor =
|
||||
field.style.borderLeftColor = field.style.borderRightColor = borderClr;
|
||||
|
||||
var inner = field.Q("unity-text-input");
|
||||
if (inner != null)
|
||||
{
|
||||
inner.style.backgroundColor = darkBg;
|
||||
inner.style.color = Color.white;
|
||||
inner.style.paddingTop = 4;
|
||||
inner.style.paddingBottom = 4;
|
||||
inner.style.paddingLeft = 6;
|
||||
inner.style.paddingRight = 6;
|
||||
}
|
||||
}
|
||||
|
||||
// One autocomplete row: the candidate token plus an optional dim hint. Rows are
|
||||
// pickingMode Ignore and keyboard-driven only — nothing here is clickable, which
|
||||
// avoids the recursive picking-mode problem documented in HUDController's chat
|
||||
// panel (clicks falling through to the game world via a ScrollView's internal
|
||||
// wrapper element).
|
||||
private static VisualElement BuildSuggestionRow(string text, string hint, bool selected)
|
||||
{
|
||||
var row = new VisualElement();
|
||||
row.pickingMode = PickingMode.Ignore;
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.minHeight = 20;
|
||||
row.style.paddingLeft = 6;
|
||||
row.style.paddingRight = 6;
|
||||
row.style.backgroundColor = selected
|
||||
? new Color(0.16f, 0.38f, 0.16f, 1f) // matches the green console cue
|
||||
: new Color(0.05f, 0.05f, 0.05f, 1f);
|
||||
|
||||
var label = new Label(text);
|
||||
label.pickingMode = PickingMode.Ignore;
|
||||
label.style.color = Color.white;
|
||||
label.style.unityFontStyleAndWeight = selected ? FontStyle.Bold : FontStyle.Normal;
|
||||
row.Add(label);
|
||||
|
||||
if (!string.IsNullOrEmpty(hint))
|
||||
{
|
||||
var hintLabel = new Label(hint);
|
||||
hintLabel.pickingMode = PickingMode.Ignore;
|
||||
hintLabel.style.color = new Color(0.6f, 0.6f, 0.6f);
|
||||
hintLabel.style.marginLeft = 12;
|
||||
row.Add(hintLabel);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
public void Show()
|
||||
{
|
||||
consoleContainer.style.display = DisplayStyle.Flex;
|
||||
consoleContainer.pickingMode = PickingMode.Position;
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
consoleContainer.style.display = DisplayStyle.None;
|
||||
consoleContainer.pickingMode = PickingMode.Ignore;
|
||||
}
|
||||
|
||||
// Windows the list around selectedIndex so arrowing past MaxVisibleSuggestions
|
||||
// keeps the selected row on screen instead of scrolling it out of the truncated view.
|
||||
public void RenderSuggestions(List<SuggestionRow> rows, int selectedIndex)
|
||||
{
|
||||
suggestionList.Clear();
|
||||
|
||||
if (rows == null || rows.Count == 0)
|
||||
{
|
||||
suggestionList.style.display = DisplayStyle.None;
|
||||
return;
|
||||
}
|
||||
|
||||
suggestionList.style.display = DisplayStyle.Flex;
|
||||
|
||||
int start = rows.Count > MaxVisibleSuggestions
|
||||
? Mathf.Clamp(selectedIndex - MaxVisibleSuggestions / 2, 0,
|
||||
rows.Count - MaxVisibleSuggestions)
|
||||
: 0;
|
||||
int end = Mathf.Min(start + MaxVisibleSuggestions, rows.Count);
|
||||
|
||||
for (int i = start; i < end; i++)
|
||||
suggestionList.Add(BuildSuggestionRow(rows[i].Text, rows[i].Hint, i == selectedIndex));
|
||||
|
||||
int hidden = rows.Count - (end - start);
|
||||
if (hidden > 0)
|
||||
suggestionList.Add(BuildSuggestionRow($"+{hidden} more", null, false));
|
||||
}
|
||||
|
||||
public void ClearSuggestions()
|
||||
{
|
||||
suggestionList.Clear();
|
||||
suggestionList.style.display = DisplayStyle.None;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/Dev/DebugConsoleUI.cs.meta
Normal file
2
Assets/_Project/Scripts/Dev/DebugConsoleUI.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: fce39b2f68ef0f3f9bec299aa46d353a
|
||||
|
|
@ -25,26 +25,22 @@ namespace TD.Gameplay.EnemyAbilities
|
|||
[Header("Blink")]
|
||||
[Tooltip("Seconds between blinks.")]
|
||||
[Min(0.1f)]
|
||||
public float IntervalSeconds = 4f;
|
||||
public float IntervalSeconds = 8f;
|
||||
|
||||
[Tooltip("How many path waypoints to skip per blink. Note that path smoothing can make " +
|
||||
"one waypoint span several tiles, so this is a coarser dial than it looks.")]
|
||||
[Min(1)]
|
||||
public int BlinkWaypoints = 2;
|
||||
|
||||
[Tooltip("Random spread (seconds) added to each enemy's first blink so a wave doesn't " +
|
||||
"blink in one synchronized block. Applied once, at spawn.")]
|
||||
[Min(0f)]
|
||||
public float StartJitterSeconds = 1.5f;
|
||||
[Tooltip("World units to advance along the path per blink.")]
|
||||
[Min(0.1f)]
|
||||
public float BlinkDistanceMeters = 4f;
|
||||
|
||||
public override void ServerOnSpawn(EnemyAbility instance, int abilityIndex)
|
||||
{
|
||||
// Seed each enemy's cooldown with a different negative offset so the wave's first
|
||||
// blink is staggered. Done once, here, rather than on the first tick — a tick-time
|
||||
// Seed each enemy's cooldown with a random point within the full interval, as if it
|
||||
// spawned already partway through a cooldown. A small jitter window near zero would
|
||||
// still let a burst of enemies spawned together sync up and blink as a visible cluster
|
||||
// every cycle; spreading across the whole interval avoids that regardless of how
|
||||
// IntervalSeconds is tuned. Done once, here, rather than on the first tick — a tick-time
|
||||
// check would have to distinguish "never started" from "just blinked", and both
|
||||
// states read as a zero timer.
|
||||
if (StartJitterSeconds > 0f)
|
||||
instance.TimerFor(abilityIndex) = -Random.Range(0f, StartJitterSeconds);
|
||||
instance.TimerFor(abilityIndex) = -Random.Range(0f, IntervalSeconds);
|
||||
}
|
||||
|
||||
public override void ServerTick(EnemyAbility instance, int abilityIndex, float dt)
|
||||
|
|
@ -54,7 +50,7 @@ namespace TD.Gameplay.EnemyAbilities
|
|||
if (timer < IntervalSeconds) return;
|
||||
|
||||
timer = 0f;
|
||||
instance.GetComponent<EnemyMovement>()?.ServerBlinkForward(BlinkWaypoints);
|
||||
instance.GetComponent<EnemyMovement>()?.ServerBlinkForward(BlinkDistanceMeters);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -286,39 +286,70 @@ namespace TD.Gameplay
|
|||
|
||||
/// <summary>
|
||||
/// Server-only: teleport this enemy forward along its existing path by up to
|
||||
/// <paramref name="tiles"/> waypoints. Used by the Blink wave buff.
|
||||
/// <paramref name="meters"/> world units. Used by the Blink wave buff.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Follows the path rather than the straight line to the goal.</b> Jumping toward the
|
||||
/// goal as the crow flies would let a grounded enemy blink through walls, which is the
|
||||
/// flying buff's job, not this one. Skipping waypoints keeps the enemy inside the maze —
|
||||
/// flying buff's job, not this one. Walking the path keeps the enemy inside the maze —
|
||||
/// it cuts corners, it doesn't ignore them.
|
||||
///
|
||||
/// <para>Zone tracking still runs on the destination tile, so an enemy that blinks out of
|
||||
/// its origin zone still credits its owner a leak. Skipping that would make the buff a way
|
||||
/// to launder leaks past the no-leak bonus.</para>
|
||||
/// <para>Distance is measured along the path (waypoint to waypoint), consuming the
|
||||
/// budget one segment at a time, so the landing spot can fall mid-segment rather than
|
||||
/// snapping to a waypoint. This keeps the blink distance predictable regardless of how
|
||||
/// far apart the path's waypoints happen to be — unlike skipping a fixed waypoint count,
|
||||
/// where one blink could span a few tiles or the length of the whole map depending on
|
||||
/// path smoothing.</para>
|
||||
///
|
||||
/// <para>Blinking onto the final waypoint resolves as a normal goal arrival, despawn and
|
||||
/// life cost included.</para>
|
||||
/// <para>Zone tracking runs once, on the final landing tile, so an enemy that blinks out
|
||||
/// of its origin zone still credits its owner a leak. Skipping that would make the buff a
|
||||
/// way to launder leaks past the no-leak bonus.</para>
|
||||
///
|
||||
/// <para>Blinking onto (or past) the final waypoint resolves as a normal goal arrival,
|
||||
/// despawn and life cost included.</para>
|
||||
/// </remarks>
|
||||
public void ServerBlinkForward(int tiles)
|
||||
public void ServerBlinkForward(float meters)
|
||||
{
|
||||
if (!IsServer || tiles <= 0) return;
|
||||
if (!IsServer || meters <= 0f) return;
|
||||
if (hasReachedGoal || remainingPath.Count == 0) return;
|
||||
if (health != null && health.IsHeld) return;
|
||||
|
||||
int skip = Mathf.Min(tiles, remainingPath.Count);
|
||||
Vector2Int destination = remainingPath[skip - 1];
|
||||
remainingPath.RemoveRange(0, skip);
|
||||
float remaining = meters;
|
||||
Vector3 position = transform.position;
|
||||
|
||||
while (remaining > 0f && remainingPath.Count > 0)
|
||||
{
|
||||
Vector3 targetWorld = GridCoordinates.GridToWorld(remainingPath[0]);
|
||||
Vector3 toTarget = targetWorld - position;
|
||||
toTarget.y = 0f;
|
||||
float distance = toTarget.magnitude;
|
||||
|
||||
if (distance <= remaining)
|
||||
{
|
||||
// Reach this waypoint fully and keep walking the leftover budget
|
||||
// into the next segment.
|
||||
remaining -= distance;
|
||||
position = new Vector3(targetWorld.x, position.y, targetWorld.z);
|
||||
remainingPath.RemoveAt(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Budget runs out partway along this segment.
|
||||
position += toTarget.normalized * remaining;
|
||||
remaining = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve Y so flyers stay at altitude and grounded pivots don't sink.
|
||||
Vector3 world = GridCoordinates.GridToWorld(destination);
|
||||
transform.position = new Vector3(world.x, transform.position.y, world.z);
|
||||
|
||||
CheckZoneTransition(destination);
|
||||
transform.position = position;
|
||||
|
||||
if (remainingPath.Count == 0)
|
||||
{
|
||||
HandleGoalReached();
|
||||
return;
|
||||
}
|
||||
|
||||
CheckZoneTransition(GridCoordinates.WorldToGrid(position));
|
||||
}
|
||||
|
||||
// ----- Path invalidation ----------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue