Files
aibis-dream/Assets/Scripts/UI/Components/DreamTerminalUI.cs
T

1213 lines
43 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections;
using System.Collections.Generic;
using Shapes;
using TMPro;
using UnityEngine;
using UnityEngine.Rendering;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace AibisDream.UI
{
/// <summary>
/// 梦境命令窗口:全部用 Shapes 即时模式绘制,无需手动配置任何 Canvas 或 UI 组件。
/// 挂在 Door 节点(或其子节点)上即可。
/// </summary>
[ExecuteAlways]
public class DreamTerminalUI : ImmediateModeShapeDrawer
{
#region 样式参数
[Header("尺寸(世界单位)")]
[SerializeField] private Vector2 terminalSize = new(6.4f, 4.1f);
[SerializeField] private Vector2 morphStartSize = new(4.9f, 7.6f);
[Header("复古像素风格")]
[SerializeField] private TMP_FontAsset terminalFont;
[SerializeField] private Color colAmberMain = new(0.81f, 0.81f, 0.81f, 1f);
[SerializeField] private Color colAmberDim = new(0.36f, 0.36f, 0.36f, 1f);
[SerializeField] private Color colWhiteGlow = new(0.95f, 0.95f, 0.95f, 1f);
[SerializeField] private Color colBg = new(0.02f, 0.02f, 0.02f, 0.97f);
[SerializeField] private Color colAlert = new(0.92f, 0.26f, 0.26f, 1f);
[Header("布局")]
[SerializeField] private float borderThickness = 0.036f;
[SerializeField] private float padding = 0.34f;
[SerializeField] private float lineHeight = 0.3f;
[SerializeField] private float fontSize = 0.22f;
[SerializeField] private float inputAreaHeight = 0.58f;
[SerializeField] private int maxVisibleLines = 9;
[Header("CRT 活性")]
[SerializeField] private float borderGhostOffset = 0.018f;
[SerializeField] private float borderGhostAlpha = 0.08f;
[SerializeField] private float borderFlickerSpeed = 1.3f;
[SerializeField] private float borderFlickerAmount = 0.05f;
[Range(0f, 0.45f)]
[SerializeField] private float borderGapRatio = 0.018f;
[SerializeField] private Vector2 textGhostOffset = new(0.015f, -0.008f);
[Range(0f, 1f)]
[SerializeField] private float textGhostAlpha = 0.12f;
[Header("复古布局")]
[SerializeField] private string terminalTitle = "AIBIS-DREAM DOS v0.2";
[SerializeField] private float headerBandHeight = 0.34f;
[SerializeField] private float footerBandHeight = 0.42f;
[Header("光标")]
[SerializeField] private float cursorBlinkRate = 0.5f;
[SerializeField] private Vector2 cursorSize = new(0.022f, 0.24f);
[SerializeField] private float cursorGlowWidth = 0.014f;
[Header("形变")]
[Range(0f, 1f)]
[SerializeField] private float morphProgress = 0f;
[Header("编辑器预览")]
[SerializeField] private bool previewInEditor = true;
[Range(0f, 1f)]
[SerializeField] private float previewAlpha = 1f;
[Range(0f, 1f)]
[SerializeField] private float previewMorphProgress = 1f;
[SerializeField] private PreviewStage previewStage = PreviewStage.WaitingInput;
[SerializeField] private bool animatePreviewCursor = true;
[SerializeField] private string previewInputBuffer = "false";
[TextArea(3, 8)]
[SerializeField] private string previewLines =
// ">> WARNING: TRAP DETECTED.\n" +
// ">> SYSTEM: Reality_Engine.sys stopped.\n" +
// ">> SYSTEM: Rendering underlying code...\n" +
// ">> LOADING: Ocean_Simulation.sh ... OK\n" +
"root@sys:~$ print_string(\"海是存在的吗?\")";
[SerializeField] private string previewTypingLine = "";
[Header("交互")]
[SerializeField] private int maxInputLength = 20;
[SerializeField] private string promptString = "bit seaExists = ";
[SerializeField] private string onCompleteNode = "苏醒";
[Header("速度(可调)")]
[Tooltip("打字机每字延迟(秒),越小越快")]
[Range(0.005f, 0.15f)]
[SerializeField] private float typewriterCharDelay = 0.025f;
[Tooltip("启动日志整体速度倍率,1=正常,2=两倍快,0.5=半速")]
[Range(0.25f, 4f)]
[SerializeField] private float bootSpeedMultiplier = 1f;
[Header("启动日志")]
[SerializeField] private List<BootLogEntry> bootSequence = new()
{
new BootLogEntry { text = "root@sys:~$ dream.render --stop --session 0742", style = LogStyle.Dim, delayAfter = 0.15f },
new BootLogEntry { text = "[ OK ] Dream renderer stopped.", style = LogStyle.Normal, delayAfter = 0.25f },
new BootLogEntry { text = "[ RAW ] Untitled_Greybox_0 exposed.", style = LogStyle.Normal, delayAfter = 0.35f },
new BootLogEntry { text = "root@sys:~$ trace --tail /log/dream_0742.rec", style = LogStyle.Dim, delayAfter = 0.15f },
new BootLogEntry { text = "[0005] audio.play(\"sea_ambient\", source=door.rear)", style = LogStyle.Dim, delayAfter = 0.1f },
new BootLogEntry { text = "[0006] memory.project(\"glass_sea\")", style = LogStyle.Dim, delayAfter = 0.1f },
new BootLogEntry { text = "[ WARN ] memory source unresolved; overflow detected.", style = LogStyle.Alert, delayAfter = 0.4f },
new BootLogEntry { text = "root@sys:~$ print_string(\"海是存在的吗?\")", style = LogStyle.Highlight, delayAfter = 0.5f },
};
#endregion
#region 状态
private enum TerminalPhase { Idle, BootSequence, WaitingInput, Confirming, EndAnimation, Done }
private TerminalPhase _phase = TerminalPhase.Idle;
// 可见文本行及其颜色
private readonly List<string> _lines = new();
private readonly List<Color> _lineColors = new();
// 打字机
private string _typingTarget = "";
private string _typingCurrent = "";
private bool _typingDone = false;
private Color _typingColor;
private Coroutine _typewriterCoroutine;
// 输入
private string _inputBuffer = "";
private bool _cursorVisible = true;
private Coroutine _crashSpamCoroutine;
private double _editorLastRepaintTime;
private IMECompositionMode _previousImeMode = IMECompositionMode.Auto;
private bool _imeOverridden;
private bool _compositionWasActive;
// 全局 alpha(用于 DreamDoorSystem 淡入淡出)
public float Alpha { get; set; } = 0f;
public float MorphProgress
{
get => morphProgress;
set => morphProgress = Mathf.Clamp01(value);
}
#endregion
#region 生命周期
private void Awake()
{
EnsureDefaultFontReference();
Alpha = 0f;
MorphProgress = 0f;
_phase = TerminalPhase.Idle;
}
private void Update()
{
if (_phase == TerminalPhase.WaitingInput)
HandleTypingInput();
else if (_phase == TerminalPhase.Confirming)
HandleConfirmInput();
}
public override void OnDisable()
{
#if UNITY_EDITOR
SceneView.duringSceneGui -= OnEditorSceneGui;
EditorApplication.update -= EditorPreviewUpdate;
if (!Application.isPlaying)
{
DrawCommand.ClearAllCommands();
_phase = TerminalPhase.Idle;
StopAllCoroutines();
return;
}
#endif
base.OnDisable();
_phase = TerminalPhase.Idle;
RestoreImeMode();
StopAllCoroutines();
}
public override void OnEnable()
{
EnsureDefaultFontReference();
#if UNITY_EDITOR
if (!Application.isPlaying)
{
SceneView.duringSceneGui -= OnEditorSceneGui;
SceneView.duringSceneGui += OnEditorSceneGui;
EditorApplication.update -= EditorPreviewUpdate;
EditorApplication.update += EditorPreviewUpdate;
EditorApply();
return;
}
#endif
base.OnEnable();
}
private void OnValidate()
{
EnsureDefaultFontReference();
MorphProgress = morphProgress;
#if UNITY_EDITOR
if (!Application.isPlaying)
EditorApply();
#endif
}
#endregion
#region 公共接口
public void Begin()
{
if (_phase != TerminalPhase.Idle) return;
_lines.Clear();
_lineColors.Clear();
_inputBuffer = "";
MorphProgress = 1f;
StartCoroutine(RunBootSequence());
}
public void ForceEnd()
{
StopAllCoroutines();
RestoreImeMode();
_phase = TerminalPhase.Done;
}
public void ResetVisualState()
{
StopAllCoroutines();
RestoreImeMode();
_phase = TerminalPhase.Idle;
_lines.Clear();
_lineColors.Clear();
_typingTarget = "";
_typingCurrent = "";
_typingDone = false;
_inputBuffer = "";
_cursorVisible = false;
MorphProgress = 0f;
}
public Vector2 EvaluateFrameSize(float progress)
{
float frameProgress = Mathf.SmoothStep(0f, 1f, Mathf.Clamp01(progress));
return Vector2.Lerp(morphStartSize, terminalSize, frameProgress);
}
[ContextMenu("Preview/Frame Only")]
private void PreviewFrameOnly()
{
previewInEditor = true;
previewStage = PreviewStage.FrameOnly;
previewAlpha = 1f;
previewMorphProgress = 0.65f;
EditorApply();
}
[ContextMenu("Preview/Waiting Input")]
private void PreviewWaitingInput()
{
previewInEditor = true;
previewStage = PreviewStage.WaitingInput;
previewAlpha = 1f;
previewMorphProgress = 1f;
EditorApply();
}
[ContextMenu("Preview/Clear")]
private void ClearPreview()
{
previewInEditor = false;
EditorApply();
}
#endregion
#region Shapes 绘制(DrawShapes 覆写)
public override void DrawShapes(Camera cam)
{
#if UNITY_EDITOR
// 编辑模式预览改走 SceneView HandlesURP 下 Shapes 文本池不会回收。
if (!Application.isPlaying)
return;
#endif
if (Alpha <= 0.01f) return;
float frameProgress = Mathf.SmoothStep(0f, 1f, MorphProgress);
float textReveal = frameProgress >= 0.999f ? 1f : 0f;
Vector2 currentSize = Vector2.Lerp(morphStartSize, terminalSize, frameProgress);
using (Draw.Command(cam))
{
Draw.BlendMode = ShapesBlendMode.Transparent;
Draw.ZTest = UnityEngine.Rendering.CompareFunction.Always;
Draw.Font = terminalFont;
Draw.FontSize = fontSize;
using (Draw.MatrixScope)
{
Draw.Matrix = transform.localToWorldMatrix;
DrawBackground(currentSize, frameProgress);
DrawBorder(currentSize, frameProgress, textReveal);
DrawLogLines(currentSize, textReveal);
DrawInputArea(currentSize, textReveal);
}
}
}
// ----- 背景 -----
private void DrawBackground(Vector2 currentSize, float frameProgress)
{
float pulse = Mathf.Lerp(0.96f, 1.02f, SampleNoise(0.11f, 0.55f));
Color bg = colBg;
bg.a *= Alpha * pulse;
Draw.Rectangle(Vector3.zero, Quaternion.identity, currentSize, bg);
float halfH = currentSize.y * 0.5f;
Color vignette = Color.black;
vignette.a = Alpha * 0.08f;
Draw.Rectangle(new Vector3(0f, halfH - 0.18f, 0f), Quaternion.identity, new Vector2(currentSize.x, 0.36f), vignette);
Draw.Rectangle(new Vector3(0f, -halfH + 0.18f, 0f), Quaternion.identity, new Vector2(currentSize.x, 0.36f), vignette);
}
// ----- 琥珀色边框 -----
private void DrawBorder(Vector2 currentSize, float frameProgress, float textReveal)
{
DrawTerminalFrame(currentSize, frameProgress);
DrawDosChrome(currentSize, frameProgress, textReveal);
}
private void DrawTerminalFrame(Vector2 currentSize, float frameProgress)
{
float frameReveal = 1f;
if (frameReveal <= 0f)
return;
float halfW = currentSize.x * 0.5f - padding * 0.48f;
float halfH = currentSize.y * 0.5f - padding * 0.42f;
float topY = halfH;
float botY = -halfH;
float leftX = -halfW;
float rightX = halfW;
Color outer = colWhiteGlow;
outer.a *= Alpha * frameReveal * 0.58f;
Color inner = colWhiteGlow;
inner.a *= Alpha * frameReveal * 0.11f;
DrawContinuousLine(new Vector3(leftX, topY, 0f), new Vector3(rightX, topY, 0f), outer, borderThickness * 0.84f, 0.51f);
DrawContinuousLine(new Vector3(leftX, botY, 0f), new Vector3(rightX, botY, 0f), outer, borderThickness * 0.84f, 0.58f);
DrawContinuousLine(new Vector3(leftX, botY, 0f), new Vector3(leftX, topY, 0f), outer, borderThickness * 0.72f, 0.64f);
DrawContinuousLine(new Vector3(rightX, botY, 0f), new Vector3(rightX, topY, 0f), outer, borderThickness * 0.72f, 0.69f);
float innerInset = borderThickness * 2.8f;
DrawContinuousLine(
new Vector3(leftX + innerInset, topY - innerInset, 0f),
new Vector3(rightX - innerInset, topY - innerInset, 0f),
inner,
borderThickness * 0.24f,
0.73f
);
DrawContinuousLine(
new Vector3(leftX + innerInset, botY + innerInset, 0f),
new Vector3(rightX - innerInset, botY + innerInset, 0f),
inner,
borderThickness * 0.2f,
0.79f
);
}
private void DrawDosChrome(Vector2 currentSize, float frameProgress, float textReveal)
{
float chromeReveal = 1f;
if (chromeReveal <= 0f)
return;
float halfW = currentSize.x * 0.5f;
float halfH = currentSize.y * 0.5f;
float frameHalfW = currentSize.x * 0.5f - padding * 0.48f;
float leftX = -halfW + padding * 0.9f;
float rightX = halfW - padding * 0.9f;
float lineLeftX = -frameHalfW;
float lineRightX = frameHalfW;
float topY = halfH - padding * 0.62f;
float separatorY = topY - GetHeaderBandHeight(currentSize) * 0.48f;
Color mainLine = colWhiteGlow;
mainLine.a *= Alpha * chromeReveal * 0.44f;
if (textReveal > 0f)
{
Draw.FontSize = fontSize * 0.74f;
mainLine.a *= textReveal;
DrawTextWithGhost(new Vector3(leftX, topY, 0f), terminalTitle, mainLine);
}
Color separator = colWhiteGlow;
separator.a *= Alpha * chromeReveal * textReveal * 0.26f;
DrawContinuousLine(
new Vector3(lineLeftX, separatorY, 0f),
new Vector3(lineRightX, separatorY, 0f),
separator,
borderThickness * 0.34f,
0.61f
);
}
private void DrawDoorOutline(Vector2 currentSize, float frameProgress)
{
float doorReveal = 1f - Mathf.InverseLerp(0.22f, 0.72f, frameProgress);
if (doorReveal <= 0f)
return;
float halfW = currentSize.x * 0.5f;
float halfH = currentSize.y * 0.5f;
float shoulderY = Mathf.Lerp(halfH - currentSize.y * 0.24f, halfH - currentSize.y * 0.12f, 1f - doorReveal);
float sideX = Mathf.Lerp(halfW * 0.78f, halfW * 0.92f, 1f - doorReveal);
float topX = Mathf.Lerp(halfW * 0.35f, halfW * 0.78f, 1f - doorReveal);
Color c = colAmberDim;
c.a *= Alpha * doorReveal * 0.72f;
DrawSegmentedLine(new Vector3(-sideX, -halfH, 0f), new Vector3(-sideX, shoulderY, 0f), c, borderThickness * 0.95f, borderGapRatio * 0.55f, 0.82f);
DrawSegmentedLine(new Vector3(sideX, -halfH, 0f), new Vector3(sideX, shoulderY, 0f), c, borderThickness * 0.82f, borderGapRatio * 0.7f, 0.87f);
DrawSegmentedLine(new Vector3(-sideX, shoulderY, 0f), new Vector3(-topX, halfH, 0f), c, borderThickness * 0.9f, borderGapRatio * 0.45f, 0.91f);
DrawSegmentedLine(new Vector3(sideX, shoulderY, 0f), new Vector3(topX, halfH, 0f), c, borderThickness * 0.78f, borderGapRatio * 0.6f, 0.96f);
DrawSegmentedLine(new Vector3(-topX, halfH, 0f), new Vector3(topX, halfH, 0f), c, borderThickness * 0.88f, borderGapRatio, 1.03f);
}
// ----- 日志文本区 -----
private void DrawLogLines(Vector2 currentSize, float textReveal)
{
if (textReveal <= 0f) return;
float halfW = currentSize.x * 0.5f;
float halfH = currentSize.y * 0.5f;
float topY = GetContentTopY(currentSize);
float bottomY = -halfH + padding * 0.88f;
int visibleLineBudget = Mathf.Max(1, Mathf.FloorToInt((topY - bottomY) / lineHeight));
int visibleLines = Mathf.Min(maxVisibleLines, visibleLineBudget);
List<RenderRow> rows = BuildRenderRows();
int startIdx = Mathf.Max(0, rows.Count - visibleLines);
for (int i = startIdx; i < rows.Count; i++)
{
int row = i - startIdx;
float y = topY - row * lineHeight;
RenderRow renderRow = rows[i];
Color c = renderRow.color;
c.a *= Alpha * textReveal;
Draw.FontSize = fontSize;
Vector3 textPos = new Vector3(-halfW + padding, y, 0f);
string displayText = renderRow.text;
if (renderRow.hasCursorSlot)
{
// 位图字体不支持 <alpha>,用 <color=#RRGGBBAA> 末两位控制透明度
string colorHex = ColorUtility.ToHtmlStringRGBA(c);
string cursorHex = renderRow.showCursor
? colorHex
: colorHex[..6] + "00";
displayText += $"<color=#{cursorHex}>{'\u2588'}</color>";
}
DrawTextWithGhost(textPos, displayText, c);
}
}
// ----- 输入区 -----
private void DrawInputArea(Vector2 currentSize, float textReveal)
{
// 输入已并入日志流,不再单独绘制底部输入区。
}
private float GetContentTopY(Vector2 currentSize)
{
float halfH = currentSize.y * 0.5f;
return halfH - padding - GetHeaderBandHeight(currentSize) * 0.46f - 0.04f;
}
private float GetHeaderBandHeight(Vector2 currentSize)
{
return Mathf.Min(headerBandHeight, currentSize.y * 0.18f);
}
private float GetFooterBandHeight(Vector2 currentSize)
{
return Mathf.Max(inputAreaHeight, Mathf.Min(footerBandHeight, currentSize.y * 0.2f));
}
private void DrawTextWithGhost(Vector3 position, string text, Color color)
{
if (string.IsNullOrEmpty(text))
return;
Color ghost = color;
ghost.a *= textGhostAlpha * 0.82f;
Draw.Text(position + new Vector3(textGhostOffset.x, textGhostOffset.y, 0f), Quaternion.identity, text, TextAlign.TopLeft, ghost);
Color phosphor = color;
phosphor.a *= 0.18f;
Draw.Text(position + new Vector3(0f, -0.01f, 0f), Quaternion.identity, text, TextAlign.TopLeft, phosphor);
Draw.Text(position, Quaternion.identity, text, TextAlign.TopLeft, color);
}
private float EstimateTextWidth(string text)
{
if (string.IsNullOrEmpty(text))
return 0f;
float width = 0f;
foreach (char c in text)
{
width += c switch
{
' ' => fontSize * 0.34f,
'.' or ',' or ':' or ';' or '!' or '|' or '\'' => fontSize * 0.24f,
'(' or ')' or '[' or ']' => fontSize * 0.36f,
_ when c > 255 => fontSize * 1.02f,
_ => fontSize * 0.58f
};
}
return width;
}
private List<RenderRow> BuildRenderRows()
{
List<RenderRow> rows = new(_lines.Count + 4);
for (int i = 0; i < _lines.Count; i++)
{
rows.Add(new RenderRow
{
text = _lines[i],
color = _lineColors.Count > i ? _lineColors[i] : colAmberMain,
showCursor = false
});
}
if (!string.IsNullOrEmpty(_typingCurrent))
{
rows.Add(new RenderRow
{
text = _typingCurrent,
color = _typingColor,
showCursor = false
});
}
if (_phase == TerminalPhase.WaitingInput || _phase == TerminalPhase.Confirming)
{
rows.Add(new RenderRow
{
text = GetInputDisplayText(),
color = colWhiteGlow,
showCursor = _phase == TerminalPhase.WaitingInput && IsCursorCurrentlyVisible(),
hasCursorSlot = _phase == TerminalPhase.WaitingInput // 始终占位,保持宽度不变
});
}
if (_phase == TerminalPhase.WaitingInput)
{
rows.Add(new RenderRow
{
text = ">> TYPE ANSWER | ENTER = CONFIRM",
color = colAmberDim,
showCursor = false
});
}
if (_phase == TerminalPhase.Confirming)
{
rows.Add(new RenderRow
{
text = ">> Submit? [Y/Enter = YES | N = EDIT]",
color = colWhiteGlow,
showCursor = false
});
}
return rows;
}
private bool IsCursorCurrentlyVisible()
{
if (!_cursorVisible)
return false;
float interval = Mathf.Max(0.08f, cursorBlinkRate);
float fullCycle = interval * 2f;
return Mathf.Repeat(GetVisualTime(), fullCycle) < interval;
}
private void CommitPendingInputToHistory(bool includeConfirmPrompt)
{
AddLine(GetInputDisplayText(), colWhiteGlow);
if (includeConfirmPrompt)
AddLine(">> Submit? [Y/Enter = YES | N = EDIT]", colWhiteGlow);
}
private string GetInputDisplayText()
{
string composition = Application.isPlaying && _phase == TerminalPhase.WaitingInput
? Input.compositionString
: string.Empty;
return promptString + _inputBuffer + composition;
}
private void DrawContinuousLine(Vector3 start, Vector3 end, Color color, float thickness, float noiseSeed)
{
DrawSegmentedLine(start, end, color, thickness, 0f, noiseSeed);
}
private void DrawSegmentedLine(Vector3 start, Vector3 end, Color color, float thickness, float gapRatio, float noiseSeed)
{
float flicker = GetBorderFlicker(noiseSeed);
float clampedGap = Mathf.Clamp(gapRatio, 0f, 0.45f);
Vector3 direction = end - start;
if (clampedGap <= 0.0005f)
{
Color straight = color;
straight.a *= flicker;
Draw.Thickness = thickness;
Draw.Line(start, end, straight);
if (borderGhostAlpha > 0.001f && borderGhostOffset > 0f && direction.sqrMagnitude > 0.0001f)
{
Vector3 simpleGhostOffset = Vector3.Cross(direction.normalized, Vector3.forward) * borderGhostOffset;
Color straightGhost = color;
straightGhost.a *= borderGhostAlpha * flicker;
Draw.Thickness = thickness * 0.45f;
Draw.Line(start + simpleGhostOffset, end + simpleGhostOffset, straightGhost);
}
return;
}
Vector3 midpoint = Vector3.Lerp(start, end, 0.5f);
Vector3 gapOffset = direction * (clampedGap * 0.5f);
Vector3 segmentAEnd = midpoint - gapOffset;
Vector3 segmentBStart = midpoint + gapOffset;
Color main = color;
main.a *= flicker;
Draw.Thickness = thickness;
Draw.Line(start, segmentAEnd, main);
Draw.Line(segmentBStart, end, main);
if (borderGhostAlpha <= 0.001f || borderGhostOffset <= 0f || direction.sqrMagnitude <= 0.0001f)
return;
Vector3 ghostOffset = Vector3.Cross(direction.normalized, Vector3.forward) * borderGhostOffset;
Color ghost = color;
ghost.a *= borderGhostAlpha * flicker;
Draw.Thickness = thickness * 0.45f;
Draw.Line(start + ghostOffset, segmentAEnd + ghostOffset, ghost);
Draw.Line(segmentBStart + ghostOffset, end + ghostOffset, ghost);
}
private float GetBorderFlicker(float seed)
{
return Mathf.Lerp(1f - borderFlickerAmount, 1f, SampleNoise(seed, borderFlickerSpeed));
}
private float SampleNoise(float seed, float speed)
{
return Mathf.PerlinNoise(seed, GetVisualTime() * speed);
}
private float GetVisualTime()
{
#if UNITY_EDITOR
if (!Application.isPlaying)
return (float)EditorApplication.timeSinceStartup;
#endif
return Time.unscaledTime;
}
#endregion
#region 打字机
private void AddLine(string text, Color color)
{
_lines.Add(text);
_lineColors.Add(color);
}
private void AppendToLastLine(string text)
{
if (_lines.Count > 0)
_lines[^1] += text;
}
private IEnumerator TypewriterLine(string text, Color color, float charDelay = 0.025f)
{
_typingTarget = text;
_typingCurrent = "";
_typingColor = color;
_typingDone = false;
foreach (char c in text)
{
_typingCurrent += c;
float delay = charDelay * UnityEngine.Random.Range(0.8f, 1.4f);
if (c is '.' or ':' or '/' or '_' or '"' or '?')
delay *= 2f;
else if (c == ' ')
delay *= 0.55f;
yield return new WaitForSeconds(delay);
}
// 提交为已完成行
AddLine(_typingCurrent, color);
_typingCurrent = "";
_typingTarget = "";
_typingDone = true;
}
private IEnumerator WaitForTypewriter()
{
while (!_typingDone)
yield return null;
}
#endregion
#region Boot Sequence
private IEnumerator RunBootSequence()
{
_phase = TerminalPhase.BootSequence;
float speed = Mathf.Max(0.25f, bootSpeedMultiplier);
yield return new WaitForSeconds(0.3f / speed);
foreach (var entry in bootSequence)
{
Color c = StyleToColor(entry.style);
float charDelay = typewriterCharDelay / speed;
_typewriterCoroutine = StartCoroutine(TypewriterLine(entry.text, c, charDelay));
yield return WaitForTypewriter();
if (entry.delayAfter > 0f)
yield return new WaitForSeconds(entry.delayAfter / speed);
}
yield return new WaitForSeconds(0.3f / speed);
EnterInputPhase();
}
private Color StyleToColor(LogStyle style) => style switch
{
LogStyle.Alert => colWhiteGlow,
LogStyle.Highlight => colWhiteGlow,
LogStyle.Dim => colAmberDim,
_ => colAmberMain
};
#endregion
#region Input Phase
private void EnterInputPhase()
{
_phase = TerminalPhase.WaitingInput;
_inputBuffer = "";
_compositionWasActive = false;
OverrideImeMode(IMECompositionMode.On);
StartCursorBlink();
}
private void HandleTypingInput()
{
string compositionBeforeInput = Input.compositionString;
bool compositionHandledEnter = _compositionWasActive || !string.IsNullOrEmpty(compositionBeforeInput);
bool enterSeen = false;
bool backspaceSeen = false;
foreach (char c in Input.inputString)
{
if (c == '\b')
{
backspaceSeen = true;
if (_inputBuffer.Length > 0)
_inputBuffer = _inputBuffer[..^1];
}
else if (c == '\n' || c == '\r')
{
enterSeen = true;
}
else if (!char.IsControl(c) && _inputBuffer.Length < maxInputLength)
{
_inputBuffer += c;
}
}
if (!backspaceSeen && Input.GetKeyDown(KeyCode.Backspace) && string.IsNullOrEmpty(Input.compositionString))
{
if (_inputBuffer.Length > 0)
_inputBuffer = _inputBuffer[..^1];
}
// inputString 在部分输入法/键盘布局下收不到回车,用 GetKeyDown 兜底
if (!enterSeen && (Input.GetKeyDown(KeyCode.Return) || Input.GetKeyDown(KeyCode.KeypadEnter)))
enterSeen = true;
string compositionAfterInput = Input.compositionString;
compositionHandledEnter |= !string.IsNullOrEmpty(compositionAfterInput);
_compositionWasActive = !string.IsNullOrEmpty(compositionAfterInput);
if (enterSeen && !compositionHandledEnter && _inputBuffer.Length > 0)
EnterConfirmPhase();
}
/// <summary>
/// 保存玩家原输入法状态,并按终端当前阶段切换 IME。
/// </summary>
private void OverrideImeMode(IMECompositionMode mode)
{
if (!_imeOverridden)
{
_previousImeMode = Input.imeCompositionMode;
_imeOverridden = true;
}
Input.imeCompositionMode = mode;
Input.compositionCursorPos = new Vector2(Screen.width * 0.5f, Screen.height * 0.62f);
}
private void RestoreImeMode()
{
if (!_imeOverridden) return;
Input.imeCompositionMode = _previousImeMode;
_imeOverridden = false;
_compositionWasActive = false;
}
#endregion
#region Confirm Phase
private void EnterConfirmPhase()
{
_phase = TerminalPhase.Confirming;
OverrideImeMode(IMECompositionMode.Off);
_compositionWasActive = false;
StopCursorBlink();
}
private void HandleConfirmInput()
{
if (Input.GetKeyDown(KeyCode.Y) || Input.GetKeyDown(KeyCode.Return) || Input.GetKeyDown(KeyCode.KeypadEnter))
{
CommitPendingInputToHistory(includeConfirmPrompt: true);
_phase = TerminalPhase.EndAnimation;
StartCoroutine(HandleSubmission(_inputBuffer));
}
else if (Input.GetKeyDown(KeyCode.N))
{
_phase = TerminalPhase.WaitingInput;
OverrideImeMode(IMECompositionMode.On);
StartCursorBlink();
}
}
#endregion
#region End Animation
private IEnumerator HandleSubmission(string input)
{
RestoreImeMode();
string val = input.Trim().ToLowerInvariant();
bool isFalse = val is "false" or "0" or "no" or "不存在";
if (isFalse)
yield return PlayShutdownEnding();
else
yield return PlayCrashEnding();
_phase = TerminalPhase.Done;
yield return new WaitForSeconds(1f);
DialogController.Instance.StartDialogNode(onCompleteNode);
DreamDoorSystem.Instance?.HideAll();
}
private IEnumerator PlayShutdownEnding()
{
AddLine(">> FALSE. SHUTTING DOWN...", colWhiteGlow);
yield return new WaitForSeconds(1.5f);
yield return DreamDoorSystem.Instance.PlayShutdownEffect();
}
private IEnumerator PlayCrashEnding()
{
AddLine(">> TRUE. ERROR: OVERFLOW.", colWhiteGlow);
yield return new WaitForSeconds(0.8f);
DreamDoorSystem.Instance.PlayCrashEffect();
_crashSpamCoroutine = StartCoroutine(SpamErrorLines());
yield return new WaitForSeconds(3f);
if (_crashSpamCoroutine != null)
StopCoroutine(_crashSpamCoroutine);
DreamDoorSystem.Instance.StopAllEffects();
}
private IEnumerator SpamErrorLines()
{
while (true)
{
string hex = UnityEngine.Random.Range(0, 0xFFFF).ToString("x4");
AddLine($"0x{hex} SYSTEM FAILURE", colAmberDim);
yield return new WaitForSeconds(0.05f);
}
}
#endregion
#region 光标闪烁
private void StartCursorBlink()
{
_cursorVisible = true;
}
private void StopCursorBlink()
{
_cursorVisible = false;
}
#endregion
#region 数据结构
[Serializable]
public class BootLogEntry
{
public string text;
public LogStyle style;
public float delayAfter;
}
private enum PreviewStage
{
Hidden,
FrameOnly,
BootText,
WaitingInput,
Confirming
}
public enum LogStyle { Normal, Alert, Highlight, Dim }
private struct RenderRow
{
public string text;
public Color color;
public bool showCursor;
public bool hasCursorSlot; // 为 true 时始终追加光标占位符,避免基线浮动
}
#endregion
private void EditorApply()
{
#if UNITY_EDITOR
ApplyEditorPreviewState();
if (previewInEditor)
SceneView.RepaintAll();
else
DrawCommand.ClearAllCommands();
#endif
}
#if UNITY_EDITOR
private void OnEditorSceneGui(SceneView sceneView)
{
if (Application.isPlaying || !previewInEditor || Alpha <= 0.01f)
return;
DrawEditorScenePreview();
}
private void DrawEditorScenePreview()
{
float frameProgress = Mathf.SmoothStep(0f, 1f, MorphProgress);
float textReveal = frameProgress >= 0.999f ? 1f : 0f;
Vector2 currentSize = Vector2.Lerp(morphStartSize, terminalSize, frameProgress);
Matrix4x4 previousMatrix = Handles.matrix;
Color previousColor = Handles.color;
CompareFunction previousZTest = Handles.zTest;
Handles.matrix = transform.localToWorldMatrix;
Handles.zTest = CompareFunction.Always;
Color bg = colBg;
bg.a *= Alpha;
Handles.DrawSolidRectangleWithOutline(
new Rect(-currentSize.x * 0.5f, -currentSize.y * 0.5f, currentSize.x, currentSize.y),
bg,
Color.clear);
float halfW = currentSize.x * 0.5f - padding * 0.48f;
float halfH = currentSize.y * 0.5f - padding * 0.42f;
Color border = colWhiteGlow;
border.a *= Alpha * 0.58f;
Handles.color = border;
Handles.DrawLine(new Vector3(-halfW, halfH, 0f), new Vector3(halfW, halfH, 0f));
Handles.DrawLine(new Vector3(-halfW, -halfH, 0f), new Vector3(halfW, -halfH, 0f));
Handles.DrawLine(new Vector3(-halfW, -halfH, 0f), new Vector3(-halfW, halfH, 0f));
Handles.DrawLine(new Vector3(halfW, -halfH, 0f), new Vector3(halfW, halfH, 0f));
if (textReveal <= 0f)
{
Handles.matrix = previousMatrix;
Handles.color = previousColor;
Handles.zTest = previousZTest;
return;
}
float contentHalfW = currentSize.x * 0.5f;
float contentHalfH = currentSize.y * 0.5f;
float titleY = contentHalfH - padding * 0.62f;
float separatorY = titleY - GetHeaderBandHeight(currentSize) * 0.48f;
DrawEditorLabel(new Vector3(-contentHalfW + padding * 0.9f, titleY, 0f), terminalTitle, colWhiteGlow, fontSize * 0.74f);
Color separator = colWhiteGlow;
separator.a *= Alpha * textReveal * 0.26f;
Handles.color = separator;
Handles.DrawLine(
new Vector3(-halfW, separatorY, 0f),
new Vector3(halfW, separatorY, 0f));
float topY = GetContentTopY(currentSize);
float bottomY = -contentHalfH + padding * 0.88f;
int visibleLineBudget = Mathf.Max(1, Mathf.FloorToInt((topY - bottomY) / lineHeight));
int visibleLines = Mathf.Min(maxVisibleLines, visibleLineBudget);
List<RenderRow> rows = BuildRenderRows();
int startIdx = Mathf.Max(0, rows.Count - visibleLines);
for (int i = startIdx; i < rows.Count; i++)
{
int row = i - startIdx;
float y = topY - row * lineHeight;
RenderRow renderRow = rows[i];
Color c = renderRow.color;
c.a *= Alpha * textReveal;
string displayText = renderRow.text;
if (renderRow.hasCursorSlot)
displayText += renderRow.showCursor ? "\u2588" : " ";
DrawEditorLabel(new Vector3(-contentHalfW + padding, y, 0f), displayText, c, fontSize);
}
Handles.matrix = previousMatrix;
Handles.color = previousColor;
Handles.zTest = previousZTest;
}
private void DrawEditorLabel(Vector3 localPosition, string text, Color color, float worldFontSize)
{
if (string.IsNullOrEmpty(text))
return;
if (_editorPreviewLabelStyle == null)
{
_editorPreviewLabelStyle = new GUIStyle(EditorStyles.label)
{
alignment = TextAnchor.UpperLeft,
richText = false
};
if (terminalFont != null && terminalFont.sourceFontFile != null)
_editorPreviewLabelStyle.font = terminalFont.sourceFontFile;
}
_editorPreviewLabelStyle.fontSize = Mathf.Clamp(Mathf.RoundToInt(worldFontSize * 12f), 8, 28);
_editorPreviewLabelStyle.normal.textColor = color;
Handles.Label(localPosition, text, _editorPreviewLabelStyle);
}
private void EditorPreviewUpdate()
{
if (Application.isPlaying || !previewInEditor || !NeedsAnimatedEditorRepaint())
return;
double now = EditorApplication.timeSinceStartup;
float interval = Mathf.Max(0.08f, cursorBlinkRate);
if (now - _editorLastRepaintTime < interval)
return;
_editorLastRepaintTime = now;
SceneView.RepaintAll();
}
private bool NeedsAnimatedEditorRepaint()
{
return animatePreviewCursor && previewStage == PreviewStage.WaitingInput;
}
private void ApplyEditorPreviewState()
{
if (Application.isPlaying)
return;
if (!previewInEditor)
{
Alpha = 0f;
MorphProgress = 0f;
_phase = TerminalPhase.Idle;
_lines.Clear();
_lineColors.Clear();
_typingCurrent = "";
_inputBuffer = "";
_cursorVisible = false;
DrawCommand.ClearAllCommands();
return;
}
Alpha = previewAlpha;
MorphProgress = previewMorphProgress;
_lines.Clear();
_lineColors.Clear();
if (!string.IsNullOrWhiteSpace(previewLines))
{
var previewEntries = previewLines.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries);
foreach (var line in previewEntries)
AddPreviewLine(line);
}
_typingCurrent = previewTypingLine ?? "";
_typingColor = colAmberMain;
_inputBuffer = previewInputBuffer ?? "";
switch (previewStage)
{
case PreviewStage.Hidden:
Alpha = 0f;
MorphProgress = 0f;
_phase = TerminalPhase.Idle;
_cursorVisible = false;
break;
case PreviewStage.FrameOnly:
_phase = TerminalPhase.Idle;
_typingCurrent = "";
_cursorVisible = false;
break;
case PreviewStage.BootText:
_phase = TerminalPhase.BootSequence;
_cursorVisible = false;
break;
case PreviewStage.WaitingInput:
_phase = TerminalPhase.WaitingInput;
_cursorVisible = animatePreviewCursor;
break;
case PreviewStage.Confirming:
_phase = TerminalPhase.Confirming;
_cursorVisible = false;
break;
}
}
private void AddPreviewLine(string text)
{
Color color = text.Contains("WARNING") || text.Contains("FALSE") || text.Contains("ERROR")
? colAlert
: text.Contains("SYSTEM") || text.Contains("LOADING")
? colAmberDim
: colAmberMain;
_lines.Add(text);
_lineColors.Add(color);
}
#endif
private void EnsureDefaultFontReference()
{
#if UNITY_EDITOR
if (terminalFont == null)
{
const string fontPath = "Assets/Font/Assets/WenQuanYi Bitmap Song 14px SDF.asset";
terminalFont = AssetDatabase.LoadAssetAtPath<TMP_FontAsset>(fontPath);
}
#endif
}
}
}