Implement native ADV retained text
This commit is contained in:
@@ -18,10 +18,17 @@ public sealed class GodotAdvHost : IHost
|
||||
private readonly GodotTimelineLog? _timeline;
|
||||
private readonly System.Threading.AutoResetEvent _frameSignal = new(false);
|
||||
private volatile bool _stopping;
|
||||
private readonly object _textLock = new();
|
||||
private readonly Dictionary<int, SurfaceTextDraw> _surfaceText = new();
|
||||
private string _advText = "";
|
||||
private int _advTextX = 100, _advTextY = 47;
|
||||
private long _advTextStartedMs;
|
||||
private bool _advTextForceComplete;
|
||||
private GfxState? _foregroundGfx;
|
||||
public volatile bool IsWaiting;
|
||||
public volatile bool IsTransitionWaiting;
|
||||
public volatile bool IsSleeping;
|
||||
public volatile bool IsTextRevealing;
|
||||
private int _presentRequested = 1;
|
||||
private long _transitionStartedAtMs = -1;
|
||||
public long TransitionStartedAtMs => System.Threading.Interlocked.Read(ref _transitionStartedAtMs);
|
||||
@@ -37,9 +44,56 @@ public sealed class GodotAdvHost : IHost
|
||||
public void ShowText(int offset, string text)
|
||||
{
|
||||
Captured.Add((offset, text));
|
||||
_main.CallDeferred("AppendLine", text);
|
||||
lock (_textLock)
|
||||
{
|
||||
_advText = text;
|
||||
_advTextStartedMs = _clock.NowMs;
|
||||
_advTextForceComplete = false;
|
||||
IsTextRevealing = text.Length > 0;
|
||||
}
|
||||
_timeline?.State("text-reveal", new()
|
||||
{
|
||||
["offset"] = $"0x{offset:x}", ["x"] = _advTextX, ["y"] = _advTextY,
|
||||
["glyphs"] = text.Length, ["delay_ms"] = 50,
|
||||
});
|
||||
while (IsTextRevealing && !_stopping)
|
||||
{
|
||||
lock (_textLock)
|
||||
{
|
||||
if (_advTextForceComplete || _clock.NowMs - _advTextStartedMs >= text.Length * 50L)
|
||||
IsTextRevealing = false;
|
||||
}
|
||||
if (IsTextRevealing) _frameSignal.WaitOne(50);
|
||||
}
|
||||
_timeline?.State("running", new() { ["text_reveal_complete"] = true });
|
||||
}
|
||||
|
||||
public void SetAdvTextCursor(int layoutSlot, int x, int y)
|
||||
{
|
||||
lock (_textLock) { _advTextX = x; _advTextY = y; }
|
||||
_timeline?.Event("text-cursor", new() { ["slot"] = layoutSlot, ["x"] = x, ["y"] = y });
|
||||
}
|
||||
|
||||
public void DrawStringToSurface(int surfaceSlot, int x, int y, string text)
|
||||
{
|
||||
lock (_textLock) _surfaceText[surfaceSlot] = new SurfaceTextDraw(x, y, text);
|
||||
_timeline?.Event("draw-string", new() { ["surface"] = surfaceSlot, ["x"] = x, ["y"] = y, ["text"] = text });
|
||||
}
|
||||
|
||||
public (string Text, int X, int Y, int VisibleGlyphs, bool Revealing) SnapshotAdvText()
|
||||
{
|
||||
lock (_textLock)
|
||||
{
|
||||
int visible = _advTextForceComplete || !IsTextRevealing
|
||||
? _advText.Length
|
||||
: (int)System.Math.Clamp((_clock.NowMs - _advTextStartedMs) / 50L + 1, 0, _advText.Length);
|
||||
return (_advText, _advTextX, _advTextY, visible, IsTextRevealing);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetSurfaceText(int surfaceSlot, out SurfaceTextDraw draw)
|
||||
{ lock (_textLock) return _surfaceText.TryGetValue(surfaceSlot, out draw); }
|
||||
|
||||
public volatile int Pages; // VM-thread page counter (incremented before IsWaiting so shot-gating can't race)
|
||||
|
||||
public void WaitForInput()
|
||||
@@ -51,6 +105,12 @@ public sealed class GodotAdvHost : IHost
|
||||
_gate.Wait();
|
||||
IsWaiting = false;
|
||||
_timeline?.State("running", new() { ["input"] = "auto-or-user" });
|
||||
lock (_textLock)
|
||||
{
|
||||
_advText = "";
|
||||
_advTextX = 100;
|
||||
_advTextY = 47;
|
||||
}
|
||||
_main.CallDeferred("ClearPage");
|
||||
}
|
||||
|
||||
@@ -58,6 +118,13 @@ public sealed class GodotAdvHost : IHost
|
||||
// the foreground lifecycle; it never pre-arms or advances the following stable input wait.
|
||||
public void SignalInput()
|
||||
{
|
||||
if (IsTextRevealing)
|
||||
{
|
||||
lock (_textLock) _advTextForceComplete = true;
|
||||
_timeline?.State("text-reveal-forced-complete", new());
|
||||
_frameSignal.Set();
|
||||
return;
|
||||
}
|
||||
if (IsTransitionWaiting && _foregroundGfx != null)
|
||||
{
|
||||
int completed = _foregroundGfx.CompleteForegroundTransitions(_clock.NowMs);
|
||||
@@ -116,12 +183,13 @@ public sealed class GodotAdvHost : IHost
|
||||
// Native retained-object writes are not front-buffer writes. The renderer publishes them only at an
|
||||
// explicit present or while the interpreter is parked in a presentation-capable service boundary.
|
||||
public bool ShouldRecomposite()
|
||||
=> IsWaiting || IsTransitionWaiting || IsSleeping ||
|
||||
=> IsWaiting || IsTransitionWaiting || IsSleeping || IsTextRevealing ||
|
||||
System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0;
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_stopping = true;
|
||||
lock (_textLock) _advTextForceComplete = true;
|
||||
if (_gate.CurrentCount == 0) _gate.Release();
|
||||
_frameSignal.Set();
|
||||
}
|
||||
@@ -160,12 +228,14 @@ public sealed class GodotAdvHost : IHost
|
||||
|
||||
public void CreateTexture(int slot, int width, int height)
|
||||
{
|
||||
lock (_textLock) _surfaceText.Remove(slot);
|
||||
_slotBmp[slot] = null; _slotDims[slot] = (width, height);
|
||||
if (TraceOps) Godot.GD.Print($"[op] create-texture slot={slot} {width}x{height}");
|
||||
}
|
||||
|
||||
public void SetTexture(long resourceId, int slot)
|
||||
{
|
||||
lock (_textLock) _surfaceText.Remove(slot);
|
||||
var asset = _res.Resolve(_scene, resourceId);
|
||||
var bmp = asset != null ? ResourceMap.TexturePath(asset) : null;
|
||||
_slotBmp[slot] = bmp;
|
||||
@@ -206,3 +276,5 @@ public sealed class GodotAdvHost : IHost
|
||||
if (path != null) _main.CallDeferred("PlayVoice", path);
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct SurfaceTextDraw(int X, int Y, string Text);
|
||||
|
||||
@@ -14,6 +14,7 @@ public partial class Main : Godot.Control
|
||||
private Image _screen = null!; // 800x600 immediate-mode canvas
|
||||
private ImageTexture _screenTex = null!;
|
||||
private Label _text = null!;
|
||||
private Label _speaker = null!;
|
||||
private Label _status = null!;
|
||||
private AudioStreamPlayer _bgm = null!; // looping background music
|
||||
private AudioStreamPlayer _voice = null!; // interrupt-on-new voice
|
||||
@@ -61,26 +62,35 @@ public partial class Main : Godot.Control
|
||||
_screenView.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||
AddChild(_screenView); // added first -> draws behind the text/status labels
|
||||
|
||||
_text = new Label { AutowrapMode = TextServer.AutowrapMode.WordSmart };
|
||||
_text = new Label { AutowrapMode = TextServer.AutowrapMode.WordSmart, MouseFilter = MouseFilterEnum.Ignore };
|
||||
_text.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||
_text.OffsetLeft = 40; _text.OffsetTop = 40; _text.OffsetRight = -40; _text.OffsetBottom = -80;
|
||||
AddChild(_text);
|
||||
_speaker = new Label { MouseFilter = MouseFilterEnum.Ignore, Visible = false };
|
||||
_speaker.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
|
||||
AddChild(_speaker);
|
||||
_status = new Label();
|
||||
_status.SetAnchorsAndOffsetsPreset(LayoutPreset.BottomWide);
|
||||
_status.OffsetLeft = 40; _status.OffsetTop = -60;
|
||||
AddChild(_status);
|
||||
|
||||
// Best-effort CJK font so the visual isn't tofu (headless self-test doesn't depend on it).
|
||||
foreach (var fp in new[] { "C:/Windows/Fonts/YuGothM.ttc", "C:/Windows/Fonts/YuGothR.ttc",
|
||||
"C:/Windows/Fonts/msgothic.ttc", "C:/Windows/Fonts/meiryo.ttc" })
|
||||
foreach (var fp in new[] { "C:/Windows/Fonts/msgothic.ttc", "C:/Windows/Fonts/YuGothM.ttc",
|
||||
"C:/Windows/Fonts/YuGothR.ttc", "C:/Windows/Fonts/meiryo.ttc" })
|
||||
{
|
||||
if (!System.IO.File.Exists(fp)) continue;
|
||||
try
|
||||
{
|
||||
var ff = new FontFile { Data = System.IO.File.ReadAllBytes(fp) };
|
||||
_text.AddThemeFontOverride("font", ff);
|
||||
_speaker.AddThemeFontOverride("font", ff);
|
||||
_status.AddThemeFontOverride("font", ff);
|
||||
_text.AddThemeFontSizeOverride("font_size", 22);
|
||||
_text.AddThemeFontSizeOverride("font_size", 25);
|
||||
_speaker.AddThemeFontSizeOverride("font_size", 25);
|
||||
_text.AddThemeConstantOverride("outline_size", 1);
|
||||
_speaker.AddThemeConstantOverride("outline_size", 1);
|
||||
var outline = new Color(0x60 / 255f, 0x60 / 255f, 0x60 / 255f, 1);
|
||||
_text.AddThemeColorOverride("font_outline_color", outline);
|
||||
_speaker.AddThemeColorOverride("font_outline_color", outline);
|
||||
break;
|
||||
}
|
||||
catch { /* fall back to the default font */ }
|
||||
@@ -189,6 +199,7 @@ public partial class Main : Godot.Control
|
||||
_host?.PulseFrame();
|
||||
if (!_selftest && _vm != null && _host != null && _host.ShouldRecomposite())
|
||||
Recomposite(); // native publishes retained mutations only at present/service boundaries
|
||||
if (!_selftest && _host != null) UpdateAdvTextPresentation();
|
||||
// --shot-sequence: dump one PNG per frame across the opening so a time-based (paced) effect can be
|
||||
// verified as distinct frames, not just the final state. Captures after Recomposite; quits when full.
|
||||
if (_seqDir != null && _seqIdx < _seqFrames && !_done)
|
||||
@@ -265,6 +276,7 @@ public partial class Main : Godot.Control
|
||||
private void Recomposite()
|
||||
{
|
||||
_screen.Fill(new Color(0, 0, 0, 0));
|
||||
_speaker.Visible = false;
|
||||
System.Collections.Generic.Dictionary<long, string>? decisions = _gfxLogPath != null || _timeline != null ? new() : null;
|
||||
int z = 0;
|
||||
var visible = _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs); // one synchronized sample for objects + ranges
|
||||
@@ -322,12 +334,30 @@ public partial class Main : Godot.Control
|
||||
}
|
||||
}
|
||||
decisions?.Add(v.Handle, $"z{z} {outcome}");
|
||||
var rawObject = _vm.Gfx.TryGet(v.Handle);
|
||||
if (rawObject != null && _host.TryGetSurfaceText(rawObject.SourceSlot, out var surfaceText))
|
||||
{
|
||||
var textPos = localToDest.Apply(surfaceText.X, surfaceText.Y);
|
||||
_speaker.Position = new Vector2((float)textPos.X, (float)textPos.Y);
|
||||
_speaker.Size = new Vector2(System.Math.Max(1, v.W - surfaceText.X), System.Math.Max(1, v.H - surfaceText.Y));
|
||||
_speaker.Text = surfaceText.Text;
|
||||
_speaker.Visible = true;
|
||||
}
|
||||
z++;
|
||||
}
|
||||
_screenTex.Update(_screen);
|
||||
if (decisions != null) LogGfxDecisionChanges(decisions);
|
||||
}
|
||||
|
||||
private void UpdateAdvTextPresentation()
|
||||
{
|
||||
var t = _host.SnapshotAdvText();
|
||||
_text.Position = new Vector2(t.X, 430 + t.Y);
|
||||
_text.Size = new Vector2(System.Math.Max(1, 720 - t.X), System.Math.Max(1, 147 - t.Y));
|
||||
int count = System.Math.Clamp(t.VisibleGlyphs, 0, t.Text.Length);
|
||||
_text.Text = count == 0 ? "" : t.Text[..count];
|
||||
}
|
||||
|
||||
private static string ColorTimeline(Age.Engine.Model.ColorTransitionState? state)
|
||||
=> state is { } c
|
||||
? $" color=0x{c.Current:x8}->0x{c.Target:x8} colorProgress={c.Progress:0.000}"
|
||||
@@ -491,7 +521,7 @@ public partial class Main : Godot.Control
|
||||
}
|
||||
|
||||
public void AppendLine(string text) => _text.Text += text + "\n";
|
||||
public void PageBreak() { _pageCount++; _status.Text = "▼ click / Enter"; }
|
||||
public void PageBreak() { _pageCount++; _status.Text = ""; }
|
||||
public void ClearPage() { _text.Text = ""; _status.Text = ""; }
|
||||
public void ShowEnd() => _status.Text = "— end —";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user