EnterTunnel fires on the first Tick after Begin for Portal/Login/Death kinds (which enter directly at Tunnel). Already implemented in Task 1.2 via _enterTunnelPending = _state == TeleportAnimState.Tunnel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
470 lines
18 KiB
C#
470 lines
18 KiB
C#
using AcDream.Core.World;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.World;
|
|
|
|
public sealed class TeleportAnimSequencerTests
|
|
{
|
|
// --- Task 1.1: type presence ---
|
|
|
|
[Fact]
|
|
public void Enums_HaveExpectedValues()
|
|
{
|
|
Assert.Equal(0, (int)TeleportAnimState.Off);
|
|
Assert.Equal(1, (int)TeleportAnimState.WorldFadeOut);
|
|
Assert.Equal(2, (int)TeleportAnimState.TunnelFadeIn);
|
|
Assert.Equal(3, (int)TeleportAnimState.Tunnel);
|
|
Assert.Equal(4, (int)TeleportAnimState.TunnelContinue);
|
|
Assert.Equal(5, (int)TeleportAnimState.TunnelFadeOut);
|
|
Assert.Equal(6, (int)TeleportAnimState.WorldFadeIn);
|
|
|
|
_ = TeleportEntryKind.Portal;
|
|
_ = TeleportEntryKind.Login;
|
|
_ = TeleportEntryKind.Death;
|
|
_ = TeleportEntryKind.Logout;
|
|
|
|
_ = TeleportAnimEvent.PlayEnterSound;
|
|
_ = TeleportAnimEvent.PlayExitSound;
|
|
_ = TeleportAnimEvent.Place;
|
|
_ = TeleportAnimEvent.FireLoginComplete;
|
|
_ = TeleportAnimEvent.EnterTunnel;
|
|
}
|
|
|
|
[Fact]
|
|
public void Snapshot_DefaultsAreOff_ClearAlpha()
|
|
{
|
|
var snap = new TeleportAnimSnapshot(
|
|
TeleportAnimState.Off, FadeAlpha: 0f, ShowTunnel: false, ShowPleaseWait: false);
|
|
Assert.Equal(TeleportAnimState.Off, snap.State);
|
|
Assert.Equal(0f, snap.FadeAlpha);
|
|
Assert.False(snap.ShowTunnel);
|
|
Assert.False(snap.ShowPleaseWait);
|
|
}
|
|
|
|
// --- Task 1.2: Begin(), IsActive, initial state ---
|
|
|
|
[Theory]
|
|
[InlineData(TeleportEntryKind.Portal, TeleportAnimState.Tunnel)]
|
|
[InlineData(TeleportEntryKind.Login, TeleportAnimState.Tunnel)]
|
|
[InlineData(TeleportEntryKind.Death, TeleportAnimState.Tunnel)]
|
|
[InlineData(TeleportEntryKind.Logout, TeleportAnimState.WorldFadeOut)]
|
|
public void Begin_SetsCorrectStartState(TeleportEntryKind kind, TeleportAnimState expectedStart)
|
|
{
|
|
var seq = new TeleportAnimSequencer();
|
|
Assert.False(seq.IsActive);
|
|
|
|
seq.Begin(kind);
|
|
|
|
Assert.True(seq.IsActive);
|
|
Assert.Equal(expectedStart, seq.State);
|
|
}
|
|
|
|
[Fact]
|
|
public void Begin_EmitsPlayEnterSoundOnFirstTick()
|
|
{
|
|
// PlayEnterSound is edge-triggered on the first Tick after Begin.
|
|
var seq = new TeleportAnimSequencer();
|
|
seq.Begin(TeleportEntryKind.Portal);
|
|
var (_, events) = seq.Tick(dt: 0f, worldReady: false);
|
|
Assert.Contains(TeleportAnimEvent.PlayEnterSound, events);
|
|
}
|
|
|
|
// --- Task 1.3: State transitions — timing-correct Tick() ---
|
|
|
|
// Helper: drive the sequencer forward by total elapsed time using small fixed steps.
|
|
private static (TeleportAnimSnapshot snap, List<TeleportAnimEvent> allEvents)
|
|
DriveSeconds(TeleportAnimSequencer seq, float seconds, bool worldReady, float step = 0.016f)
|
|
{
|
|
var allEvts = new List<TeleportAnimEvent>();
|
|
float remaining = seconds;
|
|
TeleportAnimSnapshot last = default;
|
|
while (remaining > 0f)
|
|
{
|
|
float dt = Math.Min(step, remaining);
|
|
var (snap, evts) = seq.Tick(dt, worldReady);
|
|
last = snap;
|
|
allEvts.AddRange(evts);
|
|
remaining -= dt;
|
|
}
|
|
return (last, allEvts);
|
|
}
|
|
|
|
// --- Logout path: WorldFadeOut -> TunnelFadeIn -> Tunnel ---
|
|
|
|
[Fact]
|
|
public void Logout_AfterFadeTime_TransitionsToTunnelFadeIn()
|
|
{
|
|
var seq = new TeleportAnimSequencer();
|
|
seq.Begin(TeleportEntryKind.Logout);
|
|
|
|
// Consume the enter-sound tick at dt=0
|
|
seq.Tick(0f, worldReady: false);
|
|
|
|
// Drive just past FadeTime (1.0s)
|
|
DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.02f, worldReady: false);
|
|
|
|
Assert.Equal(TeleportAnimState.TunnelFadeIn, seq.State);
|
|
}
|
|
|
|
[Fact]
|
|
public void Logout_After2xFadeTime_TransitionsToTunnel()
|
|
{
|
|
var seq = new TeleportAnimSequencer();
|
|
seq.Begin(TeleportEntryKind.Logout);
|
|
seq.Tick(0f, worldReady: false); // consume enter-sound
|
|
|
|
DriveSeconds(seq, 2f * TeleportAnimSequencer.FadeTime + 0.02f, worldReady: false);
|
|
|
|
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
|
|
}
|
|
|
|
// --- Portal/Login/Death path: enters at Tunnel ---
|
|
|
|
[Fact]
|
|
public void Portal_StartsInTunnel_HoldsWhileNotReady()
|
|
{
|
|
var seq = new TeleportAnimSequencer();
|
|
seq.Begin(TeleportEntryKind.Portal);
|
|
seq.Tick(0f, worldReady: false); // consume enter-sound
|
|
|
|
// Drive 10s — should not advance past Tunnel while !worldReady
|
|
DriveSeconds(seq, 10f, worldReady: false);
|
|
|
|
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
|
|
}
|
|
|
|
[Fact]
|
|
public void Portal_WhenWorldReady_TransitionsToTunnelContinue_EmitsEnterTunnelAndPlace()
|
|
{
|
|
var seq = new TeleportAnimSequencer();
|
|
seq.Begin(TeleportEntryKind.Portal);
|
|
seq.Tick(0f, worldReady: false); // consume enter-sound
|
|
|
|
// The very first tick with worldReady=true should advance Tunnel→TunnelContinue and emit Place.
|
|
var (_, evts) = seq.Tick(0.016f, worldReady: true);
|
|
|
|
Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
|
|
Assert.Contains(TeleportAnimEvent.Place, evts);
|
|
}
|
|
|
|
// --- TunnelContinue: MIN_CONTINUE hold then TunnelFadeOut ---
|
|
|
|
[Fact]
|
|
public void TunnelContinue_DoesNotAdvance_BeforeMinContinue()
|
|
{
|
|
var seq = new TeleportAnimSequencer();
|
|
seq.Begin(TeleportEntryKind.Portal);
|
|
seq.Tick(0f, worldReady: false);
|
|
// Enter TunnelContinue
|
|
seq.Tick(0.016f, worldReady: true);
|
|
Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
|
|
|
|
// Drive MinContinue - ε — should still be TunnelContinue
|
|
DriveSeconds(seq, TeleportAnimSequencer.MinContinue - 0.1f, worldReady: true);
|
|
|
|
Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
|
|
}
|
|
|
|
[Fact]
|
|
public void TunnelContinue_AdvancesToTunnelFadeOut_AfterMinContinue()
|
|
{
|
|
var seq = new TeleportAnimSequencer();
|
|
seq.Begin(TeleportEntryKind.Portal);
|
|
seq.Tick(0f, worldReady: false);
|
|
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
|
|
|
|
DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true);
|
|
|
|
Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
|
|
}
|
|
|
|
// --- MAX_CONTINUE forces progress even when !worldReady ---
|
|
|
|
[Fact]
|
|
public void TunnelContinue_ForcesAdvance_AtMaxContinue_EvenIfNotReady()
|
|
{
|
|
// Simulate: world never becomes fully ready but MAX_CONTINUE forces fade-out
|
|
// This path exercises the safety-net from spec §3.4.
|
|
var seq = new TeleportAnimSequencer();
|
|
seq.Begin(TeleportEntryKind.Portal);
|
|
seq.Tick(0f, worldReady: false);
|
|
// Manually push the state to TunnelContinue by passing worldReady=true for one tick,
|
|
// then simulate worldReady toggling back to false.
|
|
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
|
|
|
|
// Drive MaxContinue + ε with worldReady=false (simulating "world never loaded")
|
|
DriveSeconds(seq, TeleportAnimSequencer.MaxContinue + 0.05f, worldReady: false);
|
|
|
|
Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
|
|
}
|
|
|
|
// --- TunnelFadeOut -> WorldFadeIn: PlayExitSound edge event ---
|
|
|
|
[Fact]
|
|
public void TunnelFadeOut_EmitsPlayExitSound_OnTransitionToWorldFadeIn()
|
|
{
|
|
var seq = new TeleportAnimSequencer();
|
|
seq.Begin(TeleportEntryKind.Portal);
|
|
seq.Tick(0f, worldReady: false);
|
|
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
|
|
|
|
// Drive through MinContinue -> TunnelFadeOut
|
|
DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true);
|
|
Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
|
|
|
|
// Drive through FadeTime -> WorldFadeIn; PlayExitSound should fire on that edge
|
|
var (_, evts) = DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true);
|
|
Assert.Equal(TeleportAnimState.WorldFadeIn, seq.State);
|
|
Assert.Contains(TeleportAnimEvent.PlayExitSound, evts);
|
|
}
|
|
|
|
// --- WorldFadeIn -> Off: FireLoginComplete edge event ---
|
|
|
|
[Fact]
|
|
public void WorldFadeIn_EmitsFireLoginComplete_OnTransitionToOff()
|
|
{
|
|
var seq = new TeleportAnimSequencer();
|
|
seq.Begin(TeleportEntryKind.Portal);
|
|
seq.Tick(0f, worldReady: false);
|
|
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
|
|
DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true);
|
|
DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true); // -> WorldFadeIn
|
|
|
|
var (_, evts) = DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true);
|
|
|
|
Assert.Equal(TeleportAnimState.Off, seq.State);
|
|
Assert.False(seq.IsActive);
|
|
Assert.Contains(TeleportAnimEvent.FireLoginComplete, evts);
|
|
}
|
|
|
|
// --- Task 1.4: FadeAlpha endpoints and monotonicity ---
|
|
|
|
[Fact]
|
|
public void FadeAlpha_IsZeroAtStart_OfWorldFadeOut()
|
|
{
|
|
var seq = new TeleportAnimSequencer();
|
|
seq.Begin(TeleportEntryKind.Logout);
|
|
var (snap, _) = seq.Tick(dt: 0f, worldReady: false);
|
|
// At elapsed=0 in WorldFadeOut: smoothstep(0)=0 => alpha=0 (world fully visible).
|
|
Assert.Equal(0f, snap.FadeAlpha, precision: 4);
|
|
}
|
|
|
|
[Fact]
|
|
public void FadeAlpha_IsOneAtEnd_OfWorldFadeOut()
|
|
{
|
|
var seq = new TeleportAnimSequencer();
|
|
seq.Begin(TeleportEntryKind.Logout);
|
|
seq.Tick(0f, worldReady: false); // consume enter-sound tick, elapsed≈0
|
|
|
|
// Drive to just BEFORE the transition (so we're still in WorldFadeOut)
|
|
DriveSeconds(seq, TeleportAnimSequencer.FadeTime - 0.02f, worldReady: false);
|
|
Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State);
|
|
|
|
var (snap, _) = seq.Tick(0f, worldReady: false);
|
|
// smoothstep(clamp((1.0-0.02)/1.0,0,1)) should be close to 1
|
|
Assert.True(snap.FadeAlpha > 0.95f, $"Expected FadeAlpha near 1, got {snap.FadeAlpha}");
|
|
}
|
|
|
|
[Fact]
|
|
public void FadeAlpha_IsMonotonicallyIncreasing_DuringWorldFadeOut()
|
|
{
|
|
var seq = new TeleportAnimSequencer();
|
|
seq.Begin(TeleportEntryKind.Logout);
|
|
seq.Tick(0f, worldReady: false);
|
|
|
|
float prev = -1f;
|
|
// Sample 50 steps within FadeTime
|
|
float step = TeleportAnimSequencer.FadeTime / 50f;
|
|
for (int i = 0; i < 48; i++) // stop before transition
|
|
{
|
|
var (snap, _) = seq.Tick(step, worldReady: false);
|
|
if (seq.State != TeleportAnimState.WorldFadeOut) break;
|
|
Assert.True(snap.FadeAlpha >= prev - 0.001f,
|
|
$"Alpha decreased: {prev} -> {snap.FadeAlpha} at step {i}");
|
|
prev = snap.FadeAlpha;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void FadeAlpha_IsZero_DuringTunnelStates()
|
|
{
|
|
var seq = new TeleportAnimSequencer();
|
|
seq.Begin(TeleportEntryKind.Portal);
|
|
seq.Tick(0f, worldReady: false);
|
|
|
|
// In Tunnel state, overlay alpha should be 0 (the tunnel viewport is shown; no alpha quad needed)
|
|
var (snap, _) = seq.Tick(0.016f, worldReady: false);
|
|
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
|
|
Assert.Equal(0f, snap.FadeAlpha);
|
|
}
|
|
|
|
[Fact]
|
|
public void ShowTunnel_TrueInTunnelFamilyStates_FalseOtherwise()
|
|
{
|
|
// Logout path: WorldFadeOut -> TunnelFadeIn -> Tunnel -> TunnelContinue -> TunnelFadeOut -> WorldFadeIn -> Off
|
|
var seq = new TeleportAnimSequencer();
|
|
seq.Begin(TeleportEntryKind.Logout);
|
|
seq.Tick(0f, worldReady: false);
|
|
|
|
// WorldFadeOut: ShowTunnel = false
|
|
var (snap, _) = seq.Tick(0.016f, worldReady: false);
|
|
Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State);
|
|
Assert.False(snap.ShowTunnel);
|
|
|
|
// Advance to TunnelFadeIn
|
|
DriveSeconds(seq, TeleportAnimSequencer.FadeTime, worldReady: false);
|
|
Assert.Equal(TeleportAnimState.TunnelFadeIn, seq.State);
|
|
var (snap2, _) = seq.Tick(0f, worldReady: false);
|
|
Assert.True(snap2.ShowTunnel);
|
|
|
|
// Advance to Tunnel
|
|
DriveSeconds(seq, TeleportAnimSequencer.FadeTime, worldReady: false);
|
|
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
|
|
var (snap3, _) = seq.Tick(0f, worldReady: false);
|
|
Assert.True(snap3.ShowTunnel);
|
|
}
|
|
|
|
[Fact]
|
|
public void ShowPleaseWait_TrueOnlyInTunnelContinue()
|
|
{
|
|
var seq = new TeleportAnimSequencer();
|
|
seq.Begin(TeleportEntryKind.Portal);
|
|
seq.Tick(0f, worldReady: false);
|
|
|
|
// Tunnel: ShowPleaseWait false
|
|
var (snap1, _) = seq.Tick(0.016f, worldReady: false);
|
|
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
|
|
Assert.False(snap1.ShowPleaseWait);
|
|
|
|
// Advance to TunnelContinue
|
|
var (snap2, _) = seq.Tick(0.016f, worldReady: true);
|
|
Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
|
|
Assert.True(snap2.ShowPleaseWait);
|
|
}
|
|
|
|
// --- Task 1.5: Full portal event sequence and logout event sequence ---
|
|
|
|
// Helper: run the sequencer to completion collecting ordered events.
|
|
private static List<TeleportAnimEvent> RunToOff(
|
|
TeleportEntryKind kind, bool worldReadyAfterFirstTunnel = true, float step = 0.05f)
|
|
{
|
|
var seq = new TeleportAnimSequencer();
|
|
var allEvts = new List<TeleportAnimEvent>();
|
|
seq.Begin(kind);
|
|
|
|
int safetyNet = 5000;
|
|
while (seq.IsActive && --safetyNet > 0)
|
|
{
|
|
// Simulate world becoming ready on first Tick inside Tunnel
|
|
bool ready = worldReadyAfterFirstTunnel && seq.State == TeleportAnimState.Tunnel;
|
|
var (_, evts) = seq.Tick(step, worldReady: ready);
|
|
allEvts.AddRange(evts);
|
|
}
|
|
return allEvts;
|
|
}
|
|
|
|
[Fact]
|
|
public void Portal_FullSequence_EventsInOrder()
|
|
{
|
|
var evts = RunToOff(TeleportEntryKind.Portal);
|
|
|
|
// Required events in order: PlayEnterSound, Place, PlayExitSound, FireLoginComplete.
|
|
// EnterTunnel is optional but must come before Place if present.
|
|
int enterIdx = evts.IndexOf(TeleportAnimEvent.PlayEnterSound);
|
|
int placeIdx = evts.IndexOf(TeleportAnimEvent.Place);
|
|
int exitIdx = evts.IndexOf(TeleportAnimEvent.PlayExitSound);
|
|
int loginIdx = evts.IndexOf(TeleportAnimEvent.FireLoginComplete);
|
|
|
|
Assert.True(enterIdx >= 0, "PlayEnterSound must fire");
|
|
Assert.True(placeIdx >= 0, "Place must fire");
|
|
Assert.True(exitIdx >= 0, "PlayExitSound must fire");
|
|
Assert.True(loginIdx >= 0, "FireLoginComplete must fire");
|
|
|
|
Assert.True(enterIdx < placeIdx, "PlayEnterSound must precede Place");
|
|
Assert.True(placeIdx < exitIdx, "Place must precede PlayExitSound");
|
|
Assert.True(exitIdx < loginIdx, "PlayExitSound must precede FireLoginComplete");
|
|
}
|
|
|
|
[Fact]
|
|
public void Logout_FullSequence_EventsInOrder_WithEnterTunnelAfterFades()
|
|
{
|
|
var evts = RunToOff(TeleportEntryKind.Logout);
|
|
|
|
int enterIdx = evts.IndexOf(TeleportAnimEvent.PlayEnterSound);
|
|
int tunnelIdx = evts.IndexOf(TeleportAnimEvent.EnterTunnel);
|
|
int placeIdx = evts.IndexOf(TeleportAnimEvent.Place);
|
|
int exitIdx = evts.IndexOf(TeleportAnimEvent.PlayExitSound);
|
|
int loginIdx = evts.IndexOf(TeleportAnimEvent.FireLoginComplete);
|
|
|
|
Assert.True(enterIdx >= 0, "PlayEnterSound must fire");
|
|
Assert.True(tunnelIdx >= 0, "EnterTunnel must fire (Logout path goes through WorldFadeOut->TunnelFadeIn->Tunnel)");
|
|
Assert.True(placeIdx >= 0, "Place must fire");
|
|
Assert.True(exitIdx >= 0, "PlayExitSound must fire");
|
|
Assert.True(loginIdx >= 0, "FireLoginComplete must fire");
|
|
|
|
Assert.True(enterIdx < tunnelIdx, "PlayEnterSound must precede EnterTunnel");
|
|
Assert.True(tunnelIdx < placeIdx, "EnterTunnel must precede Place");
|
|
Assert.True(placeIdx < exitIdx, "Place must precede PlayExitSound");
|
|
Assert.True(exitIdx < loginIdx, "PlayExitSound must precede FireLoginComplete");
|
|
}
|
|
|
|
[Fact]
|
|
public void Portal_NoEventsFiredTwice()
|
|
{
|
|
var evts = RunToOff(TeleportEntryKind.Portal);
|
|
Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.PlayEnterSound));
|
|
Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.Place));
|
|
Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.PlayExitSound));
|
|
Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.FireLoginComplete));
|
|
}
|
|
|
|
[Fact]
|
|
public void Death_BehavesIdenticallyToPortal_FullSequence()
|
|
{
|
|
var portalEvts = RunToOff(TeleportEntryKind.Portal);
|
|
var deathEvts = RunToOff(TeleportEntryKind.Death);
|
|
// Same event sequence (both enter at Tunnel)
|
|
Assert.Equal(portalEvts, deathEvts);
|
|
}
|
|
|
|
[Fact]
|
|
public void Login_BehavesIdenticallyToPortal_FullSequence()
|
|
{
|
|
var portalEvts = RunToOff(TeleportEntryKind.Portal);
|
|
var loginEvts = RunToOff(TeleportEntryKind.Login);
|
|
Assert.Equal(portalEvts, loginEvts);
|
|
}
|
|
|
|
[Fact]
|
|
public void AfterOff_IsActiveIsFalse_AndStateIsOff()
|
|
{
|
|
RunToOff(TeleportEntryKind.Portal);
|
|
var seq = new TeleportAnimSequencer();
|
|
seq.Begin(TeleportEntryKind.Portal);
|
|
|
|
// Drain to Off
|
|
for (int i = 0; i < 5000 && seq.IsActive; i++)
|
|
{
|
|
bool ready = seq.State == TeleportAnimState.Tunnel;
|
|
seq.Tick(0.05f, worldReady: ready);
|
|
}
|
|
|
|
Assert.False(seq.IsActive);
|
|
Assert.Equal(TeleportAnimState.Off, seq.State);
|
|
}
|
|
|
|
// --- Task 1.6: EnterTunnel fired for Portal path via Begin() ---
|
|
|
|
[Fact]
|
|
public void Portal_EmitsEnterTunnel_OnFirstTick()
|
|
{
|
|
// Portal begins directly in Tunnel; EnterTunnel signals "world is now hidden".
|
|
var seq = new TeleportAnimSequencer();
|
|
seq.Begin(TeleportEntryKind.Portal);
|
|
|
|
// First tick: should emit PlayEnterSound AND EnterTunnel (both on entry)
|
|
var (_, evts) = seq.Tick(0f, worldReady: false);
|
|
Assert.Contains(TeleportAnimEvent.PlayEnterSound, evts);
|
|
Assert.Contains(TeleportAnimEvent.EnterTunnel, evts);
|
|
}
|
|
}
|