This commit is contained in:
Rowan 2025-07-19 23:42:43 -04:00
parent 4ea0f58bc7
commit 4456431f25
22 changed files with 334 additions and 127 deletions

View file

@ -15,11 +15,11 @@ MonoBehaviour:
addTransition: 1
propertyName: opacity
duration:
duration: 0.3
duration: 1
unit: 2
easing: 3
delay:
duration: 0
duration: 1
unit: 2
from: 0
to: 1

View file

@ -0,0 +1,25 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 94cc50e7b5f20a817aef3384d0889279, type: 3}
m_Name: Fade Out
m_EditorClassIdentifier:
addTransition: 1
propertyName: opacity
duration:
duration: 1
unit: 2
easing: 3
delay:
duration: 1
unit: 2
from: 1
to: 0

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: fd3397ffea6cbccc5992ef2e70a2ed44
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View file

@ -14,7 +14,9 @@ MonoBehaviour:
m_EditorClassIdentifier:
visualTreeAsset: {fileID: 9197481963319205126, guid: bc70bdaaf37609283aac14435c5441a2, type: 3}
effects:
- timing: 0
- uiEvent: 0
effect: {fileID: 11400000, guid: 0902dc21238be2bb3ae758e1d5218922, type: 2}
- uiEvent: 1
effect: {fileID: 11400000, guid: fd3397ffea6cbccc5992ef2e70a2ed44, type: 2}
titleId: title
contentId: content

View file

@ -922,6 +922,9 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
oneTimeUse: 0
onTarget:
m_PersistentCalls:
m_Calls: []
onInteracted:
m_PersistentCalls:
m_Calls: []
@ -1211,7 +1214,6 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
camera: {fileID: 330585546}
registerChildren: 1
--- !u!114 &1281046470
MonoBehaviour:
m_ObjectHideFlags: 0
@ -1227,6 +1229,9 @@ MonoBehaviour:
orchestrator: {fileID: 11400000, guid: 994ab04db2c2004abb8272830cb16b26, type: 2}
root: {fileID: 1281046468}
panelSettings: {fileID: 11400000, guid: 2bc58aab5867867e5b0feeae2df42fd0, type: 2}
despawnTimeout:
duration: 2
unit: 2
prefab: {fileID: 0}
--- !u!1 &1363717994
GameObject:
@ -2323,6 +2328,34 @@ PrefabInstance:
propertyPath: oneTimeUse
value: 1
objectReference: {fileID: 0}
- target: {fileID: 2022912019066975223, guid: 51202e3c62c3d3e1c9bb0ff9c09a608f, type: 3}
propertyPath: onTarget.m_PersistentCalls.m_Calls.Array.size
value: 1
objectReference: {fileID: 0}
- target: {fileID: 2022912019066975223, guid: 51202e3c62c3d3e1c9bb0ff9c09a608f, type: 3}
propertyPath: onTarget.m_PersistentCalls.m_Calls.Array.data[0].m_Mode
value: 1
objectReference: {fileID: 0}
- target: {fileID: 2022912019066975223, guid: 51202e3c62c3d3e1c9bb0ff9c09a608f, type: 3}
propertyPath: onTarget.m_PersistentCalls.m_Calls.Array.data[0].m_Target
value:
objectReference: {fileID: 1740748263}
- target: {fileID: 2022912019066975223, guid: 51202e3c62c3d3e1c9bb0ff9c09a608f, type: 3}
propertyPath: onTarget.m_PersistentCalls.m_Calls.Array.data[0].m_CallState
value: 2
objectReference: {fileID: 0}
- target: {fileID: 2022912019066975223, guid: 51202e3c62c3d3e1c9bb0ff9c09a608f, type: 3}
propertyPath: onTarget.m_PersistentCalls.m_Calls.Array.data[0].m_MethodName
value: Toggle
objectReference: {fileID: 0}
- target: {fileID: 2022912019066975223, guid: 51202e3c62c3d3e1c9bb0ff9c09a608f, type: 3}
propertyPath: onTarget.m_PersistentCalls.m_Calls.Array.data[0].m_TargetAssemblyTypeName
value: KitsuneCafe.UI.TestThing, Assembly-CSharp
objectReference: {fileID: 0}
- target: {fileID: 2022912019066975223, guid: 51202e3c62c3d3e1c9bb0ff9c09a608f, type: 3}
propertyPath: onTarget.m_PersistentCalls.m_Calls.Array.data[0].m_Arguments.m_ObjectArgumentAssemblyTypeName
value: UnityEngine.Object, UnityEngine
objectReference: {fileID: 0}
- target: {fileID: 2368094401510200604, guid: 51202e3c62c3d3e1c9bb0ff9c09a608f, type: 3}
propertyPath: m_LocalPosition.x
value: -2

View file

@ -0,0 +1,19 @@
using System.Threading;
using R3;
using UnityEngine.UIElements;
namespace KitsuneCafe.Extension
{
public static class VisualElementExtensions
{
public static Observable<T> ObserveEvent<T>(this VisualElement target, CancellationToken token = default) where T : EventBase<T>, new()
{
return Observable.FromEvent<EventCallback<T>, T>(
h => e => h(e),
e => target.RegisterCallback(e),
e => target.UnregisterCallback(e),
token
);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: aaf4cd39f81ac6dd8b5949c0f4a2f7ef

View file

@ -80,6 +80,8 @@ namespace KitsuneCafe.Interaction
}
bool IsInteractable { get; }
virtual void Target(IInteractor interactor) { }
IResult<Unit, InteractionError> Interact(IInteractor interactor);
}
}

View file

@ -9,6 +9,9 @@ namespace KitsuneCafe.Interaction
[SerializeField]
private bool oneTimeUse = false;
[SerializeField]
private UnityEvent<IInteractor> onTarget = default;
[SerializeField]
private UnityEvent<IInteractor> onInteracted = default;
@ -16,6 +19,12 @@ namespace KitsuneCafe.Interaction
public bool IsInteractable => isInteractable;
void IInteractable.Target(IInteractor interactor)
{
onTarget.Invoke(interactor);
}
public IResult<Unit, InteractionError> Interact(IInteractor interactor)
{
onInteracted.Invoke(interactor);

View file

@ -169,6 +169,8 @@ namespace KitsuneCafe.Player
{
if (interactable == null) { return; }
interactable.Target(this);
if (!interactable.TryGetComponent(out Outline outline))
{
outline = interactable.gameObject.AddComponent<Outline>();

View file

@ -8,9 +8,6 @@ namespace KitsuneCafe.Rendering
[SerializeField]
private new Transform camera;
[SerializeField]
private bool registerChildren = false;
private readonly List<Transform> transforms = new();
private void Reset()

View file

@ -1,4 +1,5 @@
using System;
using UnityEngine.UIElements;
namespace KitsuneCafe.System
{
@ -26,8 +27,10 @@ namespace KitsuneCafe.System
public Duration(long ticks) : this(ticks, TimeUnit.Ticks) { }
public static implicit operator TimeSpan(Duration duration) => duration.AsTimeSpan();
public static implicit operator TimeValue(Duration duration) => duration.AsTimeValue();
public readonly TimeSpan AsTimeSpan() => new(Value);
public readonly TimeValue AsTimeValue() => new(Into(TimeUnit.Seconds));
public static Duration From(long value, TimeUnit unit)
{

View file

@ -15,10 +15,7 @@ namespace KitsuneCafe.System
public static implicit operator Duration(SerializableDuration d) => Duration.From(d.duration, d.unit);
public static implicit operator TimeValue(SerializableDuration d)
{
Duration duration = d;
return new TimeValue(duration.Into(TimeUnit.Seconds));
}
public static implicit operator TimeSpan(SerializableDuration d) => Duration.From(d.duration, d.unit);
public static implicit operator TimeValue(SerializableDuration d) => Duration.From(d.duration, d.unit);
}
}

View file

@ -27,7 +27,7 @@ namespace KitsuneCafe.UI
return new StyleList<T>(new List<T> { value });
}
public Observable<R3.Unit> Execute(VisualElement target, CancellationToken token)
public Observable<Unit> Execute(VisualElement target, CancellationToken token)
{
target.style.transitionProperty = ToStyleList(PropertyName);
target.style.transitionDuration = ToStyleList(Duration);

View file

@ -4,6 +4,8 @@ using KitsuneCafe.System.Attributes;
using R3;
using UnityEngine;
using UnityEngine.UIElements;
using KitsuneCafe.Extension;
using Unit = R3.Unit;
namespace KitsuneCafe.UI
{
@ -55,25 +57,35 @@ namespace KitsuneCafe.UI
To = to;
}
private void DoTransition(VisualElement target)
private Observable<Unit> ObserveGeometryChange(VisualElement target, CancellationToken token)
{
target.style.opacity = From;
target.style.opacity = To;
}
public Observable<R3.Unit> Execute(VisualElement target, CancellationToken token)
{
target.style.opacity = From;
var to = To;
return Observable.NextFrame(cancellationToken: token)
.Do(_ =>
{
target.style.opacity = to;
})
return target.ObserveEvent<GeometryChangedEvent>(token)
.OnErrorResumeAsFailure()
.AsUnitObservable();
}
private Observable<Unit> Defer(VisualElement target, CancellationToken token)
{
return Observable.Merge(
ObserveGeometryChange(target, token),
Observable.EveryUpdate(UnityFrameProvider.PostLateUpdate, token)
)
.Where(_ => target.resolvedStyle.width > 0 && target.resolvedStyle.height > 0)
.Take(1);
}
public Observable<Unit> Execute(VisualElement target, CancellationToken token)
{
target.style.opacity = From;
var to = To;
return Defer(target, token)
.Do(_ => target.style.opacity = to)
.Select(_ => target.ObserveEvent<TransitionEndEvent>())
.Switch()
.Where(evt => evt.stylePropertyNames.Contains("opacity"))
.Take(1)
.TakeUntil(token)
.AsUnitObservable();
}
}
}

View file

@ -0,0 +1,143 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using KitsuneCafe.System;
using R3;
using UnityEngine;
using UnityEngine.UIElements;
using Unit = R3.Unit;
namespace KitsuneCafe.UI
{
public class UiElementInstance : MonoBehaviour, IDisposable
{
public UIDocument Document;
public PooledObject PooledObject;
private IUiElement uiElement;
private VisualElement visualElement;
private DisposableBag activeEffects;
private IDisposable instance;
private CancellationTokenSource cts;
private void OnDisable()
{
Dispose();
}
/// <summary>
/// Initial configuration of the UIDocument. Called by UISceneManager on spawn/reuse.
/// </summary>
public void Configure(IUiElement element, PanelSettings settings)
{
uiElement = element;
cts = new();
Document.visualTreeAsset = element.VisualTreeAsset;
Document.panelSettings = settings;
instance = ConfigureWhenReady(element)
.Subscribe();
}
/// <summary>
/// Internal configuration of the VisualElement. Called when rootVisualElement is ready.
/// </summary>
private void Configure(IUiElement uiElement, VisualElement visualElement)
{
this.visualElement = visualElement;
uiElement.Configure(visualElement);
Raise(UiEvent.OnCreate, cts.Token)
.Subscribe()
.AddTo(ref activeEffects);
}
/// <summary>
/// Waits for the UIDocument's rootVisualElement to be ready.
/// </summary>
private Observable<Unit> ConfigureWhenReady(IUiElement element)
{
return Observable.EveryValueChanged(Document, d => d.rootVisualElement)
.WhereNotNull()
.Take(1)
.TakeUntil(cts.Token)
.Do(root => Configure(element, root))
.AsUnitObservable();
}
/// <summary>
/// Raises effects for a given UI event, returning an Observable that completes when all effects are done.
/// </summary>
private Observable<Unit> Raise(UiEvent evt, CancellationToken token = default)
{
var effects = uiElement.GetEffectsFor(evt).ToArray();
var obs = new List<Observable<Unit>>(effects.Length);
foreach (var effect in effects)
{
if (effect != null)
{
obs.Add(effect
.Instantiate()
.Execute(visualElement, token));
}
}
return Observable.Merge(obs);
}
/// <summary>
/// Forcefully disposes of all active effects and instance lifecycle subscriptions.
/// This is for internal cleanup. It does NOT release the object to the pool.
/// </summary>
public void Dispose()
{
cts?.Cancel();
cts?.Dispose();
cts = null;
// activeEffects.Dispose();
activeEffects.Clear();
instance?.Dispose();
uiElement = default;
visualElement = default;
}
/// <summary>
/// Initiates the despawn process: runs OnDestroy effects, waits for them to finish,
/// then disposes everything and releases the object back to the pool.
/// </summary>
public Observable<Unit> Despawn(CancellationTokenSource cts = default)
{
cts ??= new CancellationTokenSource();
return Raise(UiEvent.OnDestroy, cts.Token)
.Do(onCompleted: _ =>
{
Dispose();
PooledObject.Release();
},
onErrorResume: _ => cts.Cancel())
.TakeUntil(cts.Token)
.AsUnitObservable();
}
/// <summary>
/// Immediately disposes of effects and releases the object to the pool,
/// without awaiting OnDestroy effects.
/// </summary>
public void DespawnNow()
{
Dispose();
PooledObject.Release();
}
}
}

View file

@ -5,7 +5,6 @@ using System;
using UnityEngine.UIElements;
using UnityEngine.Pool;
using KitsuneCafe.System;
using KitsuneCafe.Extension;
namespace KitsuneCafe.UI
{
@ -15,6 +14,7 @@ namespace KitsuneCafe.UI
[SerializeField] private Transform root;
[SerializeField] private PanelSettings panelSettings;
[SerializeField] private SerializableDuration despawnTimeout;
[SerializeField] private GameObject prefab;
private readonly Dictionary<ElementId, GameObject> activeElements = new();
@ -74,17 +74,24 @@ namespace KitsuneCafe.UI
}
else
{
obj = new GameObject("Pooled Object", typeof(UIDocument));
obj = new GameObject(
"Pooled Object",
typeof(UIDocument),
typeof(PooledObject),
typeof(UiElementInstance)
);
}
obj.AddComponent<UiElementInstance>();
var doc = obj.GetComponent<UIDocument>();
doc.panelSettings = panelSettings;
var poolObj = obj.AddComponent<PooledObject>();
var poolObj = obj.GetComponent<PooledObject>();
poolObj.objectPool = pool;
var instance = obj.GetComponent<UiElementInstance>();
instance.Document = doc;
instance.PooledObject = poolObj;
obj.transform.SetParent(root);
return obj;
@ -110,48 +117,42 @@ namespace KitsuneCafe.UI
return GetOrCreatePool(element).Get();
}
private Observable<T> ObserveEvent<T>(VisualElement target) where T : EventBase<T>, new()
{
return Observable.FromEvent<EventCallback<T>, T>(
h => e => h(e),
e => target.RegisterCallback(e),
e => target.UnregisterCallback(e)
);
}
private void SpawnElement(ElementId id, IUiElement element, Vector3 worldPosition)
{
var obj = GetUiDocument(element);
activeElements[id] = obj;
var trans = obj.transform;
trans.SetParent(root);
trans.SetPositionAndRotation(worldPosition, Quaternion.identity);
trans.localScale = Vector3.one;
if (obj.TryGetComponent(out UIDocument doc) && obj.TryGetComponent(out UiElementInstance instance))
if (obj.TryGetComponent(out UiElementInstance instance))
{
doc.visualTreeAsset = element.VisualTreeAsset;
doc.panelSettings = panelSettings;
Observable.EveryValueChanged(doc, d => d.rootVisualElement)
.WhereNotNull()
.Select(_ => doc)
.Subscribe(doc =>
{
instance.Configure(element, doc.rootVisualElement);
})
.AddTo(doc.gameObject);
}
activeElements[id] = obj;
instance.Configure(element, panelSettings);
}
}
// FIXME: track which pool each active element belongs to?
private void DespawnElement(ElementId id)
{
if (activeElements.TryGetValue(id, out GameObject obj))
{
obj.GetComponent<PooledObject>().Release();
if (obj.TryGetComponent<UiElementInstance>(out var instance))
{
instance.Despawn()
.Race(Observable.Timer(despawnTimeout).Do(_ => instance.DespawnNow()))
.Do(onCompleted: _ => activeElements.Remove(id))
.Subscribe()
.AddTo(obj);
}
else if (obj.TryGetComponent(out PooledObject poolObj))
{
poolObj.Release();
activeElements.Remove(id);
}
}
}
}
}

View file

@ -10,9 +10,32 @@ namespace KitsuneCafe.UI
[SerializeField]
private ModalElement modal;
private void Start()
private ElementId id;
public void Open()
{
orchestrator.SpawnElement(modal.Create("test", "fuck !!!! :3"), transform.position + Vector3.up);
id = orchestrator.SpawnElement(modal.Create("<3", "fuck !!!! :3"), transform.position + (Vector3.up * 2));
}
public void Close()
{
if (id != default)
{
orchestrator.DespawnElement(id);
id = default;
}
}
public void Toggle()
{
if (id != default)
{
Close();
}
else
{
Open();
}
}
}
}

View file

@ -1,71 +0,0 @@
using System;
using R3;
using UnityEngine;
using UnityEngine.UIElements;
namespace KitsuneCafe.UI
{
public class UiElementInstance : MonoBehaviour, IDisposable
{
private IUiElement uiElement;
private VisualElement visualElement;
private readonly CompositeDisposable activeEffects = new();
private bool configured = false;
public void Configure(IUiElement uiElement, VisualElement visualElement)
{
configured = true;
this.uiElement = uiElement;
this.visualElement = visualElement;
activeEffects.Clear();
uiElement.Configure(visualElement);
Raise(UiEvent.OnCreate);
}
private void Raise(UiEvent evt)
{
foreach (var effect in uiElement.GetEffectsFor(evt))
{
// using explicit null comparison because of the way
// GameObjects handle being null
if (effect != null)
{
effect.Instantiate()
.Execute(visualElement, destroyCancellationToken)
.Subscribe()
.AddTo(activeEffects);
}
}
}
public void Dispose()
{
if (!configured) { return; }
Raise(UiEvent.OnDestroy);
activeEffects.Dispose();
activeEffects.Clear();
uiElement = default;
visualElement = default;
configured = false;
}
private void OnDisable()
{
Dispose();
}
private void OnDestroy()
{
Dispose();
}
}
}