-
-
Save CodingDino/022eff65dd00e5e6e92195780fd03dce to your computer and use it in GitHub Desktop.
Gameplay programming example: For Armoured Engines, which is similar to a shmup, I developed a "stage" paradigm to program entrances and exits of various components as "cues" for things to happen on the "stage" (current Unity scene). This code sample shows the CueSeries, which contains all the various cues in a level, and logic for loading and p…
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ************************************************************************ | |
// File Name: CueSeries.cs | |
// Purpose: A list of Cues | |
// Project: Armoured Engines | |
// Author: Sarah Herzog | |
// Copyright: 2017 Bounder Games | |
// ************************************************************************ | |
// ************************************************************************ | |
#region Imports | |
// ************************************************************************ | |
using System.Collections; | |
using System.Collections.Generic; | |
using UnityEngine; | |
using System.IO; | |
using System; | |
#if UNITY_EDITOR | |
using UnityEditor; | |
#endif | |
#endregion | |
// ************************************************************************ | |
// ************************************************************************ | |
#region Class: CueSeries | |
// ************************************************************************ | |
[System.Serializable] | |
[CreateAssetMenu(fileName = "Data", menuName = "Bounder/Cues/CueSeries", order = 1)] | |
public class CueSeries : ScriptableObject, IncrementalLoader | |
{ | |
// ******************************************************************** | |
#region Public Data Members | |
// ******************************************************************** | |
public List<Cue> cues = new List<Cue>(); | |
#endregion | |
// ******************************************************************** | |
// ******************************************************************** | |
#region Private Data Members | |
// ******************************************************************** | |
private float m_progress; | |
private string m_action; | |
#if UNITY_EDITOR | |
private List<Cue> m_selectedCues = new List<Cue>(); | |
#endif | |
#endregion | |
// ******************************************************************** | |
// ******************************************************************** | |
#region Properties | |
// ******************************************************************** | |
public bool loaded { get; set; } | |
public float startTime { get; set; } | |
public float runTime { get; set; } | |
public bool paused { get; set; } | |
public int index { get; set; } | |
public string currentCue { get; set; } | |
#if UNITY_EDITOR | |
public List<Cue> selectedCues { get { return m_selectedCues; } set { m_selectedCues = value; } } | |
#endif | |
#endregion | |
// ******************************************************************** | |
// ******************************************************************** | |
#region IncrementalLoader Methods | |
// ******************************************************************** | |
public float GetProgress() { return m_progress; } | |
public string GetCurrentAction() { return m_action; } | |
// ******************************************************************** | |
#endregion | |
// ******************************************************************** | |
// ******************************************************************** | |
#region Public Methods | |
// ******************************************************************** | |
public void Reset(float _startTime = 0) | |
{ | |
startTime = _startTime; | |
runTime = startTime; | |
paused = false; | |
} | |
// ******************************************************************** | |
public IEnumerator Load() | |
{ | |
// Trigger all resources to load | |
for (int i = 0; i < cues.Count; ++i) | |
{ | |
Cue cue = cues[i]; | |
cue.Load(); | |
cue.RegisterCondition(true); | |
m_progress = 0.5f * (((float)i)/((float)cues.Count)); | |
m_action = cue.name; | |
yield return null; | |
} | |
m_progress = 1.0f; | |
} | |
// ******************************************************************** | |
public void UnLoad() | |
{ | |
// Trigger all resources to unload | |
for (int i = 0; i < cues.Count; ++i) | |
{ | |
Cue cue = cues[i]; | |
cue.RegisterCondition(false); | |
} | |
} | |
// ******************************************************************** | |
public IEnumerator Play() | |
{ | |
Debug.Log("CueSeries.Play() - "+name); | |
List<Cue> sortedCues = cues.Copy(); | |
sortedCues.Sort( | |
delegate(Cue p1, Cue p2) | |
{ | |
if (p1.enterTime != p2.enterTime) | |
return p1.enterTime.CompareTo(p2.enterTime); | |
else | |
return cues.IndexOf(p1).CompareTo(cues.IndexOf(p2)); | |
} | |
); | |
for (int i = 0; i < sortedCues.Count; ++i) | |
{ | |
index = i; | |
Cue cue = sortedCues[i]; | |
if (cue.enterTime < startTime) | |
continue; | |
while (paused || cue.enterTime > runTime) | |
{ | |
if (!paused) | |
runTime += Time.deltaTime; | |
yield return null; | |
} | |
if (cue.shouldRun) | |
{ | |
Debug.Log("CueSeries playing cue - "+cue.name); | |
CueManager.StartCoroutineStatic(cue.Enter()); | |
currentCue = cue.name; | |
} | |
if (cue.exitTime != 0 && cue.shouldRun) | |
CueManager.StartCoroutineStatic(cue.ExitOnTime()); | |
} | |
float finalTime = GetFinalTime(); | |
while (runTime < finalTime) | |
{ | |
if (!paused) | |
runTime += Time.deltaTime; | |
yield return null; | |
} | |
// Unload when finished | |
CueManager.UnloadSeries(this); | |
} | |
// ******************************************************************** | |
public float GetFinalTime() | |
{ | |
float finalTime = 0; | |
for (int i = 0; i < cues.Count; ++i) | |
{ | |
if (cues[i].finalTime > finalTime) | |
finalTime = cues[i].finalTime; | |
} | |
return finalTime; | |
} | |
// ******************************************************************** | |
#endregion | |
// ******************************************************************** | |
// ******************************************************************** | |
#if UNITY_EDITOR | |
// ******************************************************************** | |
public void AddCue(object _type) | |
{ | |
AddCue(CreateInstance(_type as Type) as Cue); | |
} | |
// ******************************************************************** | |
public void AddCue(Cue _cue) | |
{ | |
AddCueAt(_cue,cues.Count); | |
} | |
// ******************************************************************** | |
public void AddCueAt(Cue _cue, int _pos) | |
{ | |
// Add cue | |
cues.Insert(_pos,_cue); | |
_cue.series = this; | |
_cue.expanded = true; | |
_cue.selected = false; | |
// Set dirty | |
EditorUtility.SetDirty(this); | |
// Create the asset and add it to the project | |
string parentFolder = Path.GetDirectoryName(AssetDatabase.GetAssetPath(this)); | |
string assetPath = parentFolder+"/Cues"; | |
if (!AssetDatabase.IsValidFolder(assetPath)) | |
{ | |
string newFolderID = AssetDatabase.CreateFolder(parentFolder,"Cues"); | |
assetPath = AssetDatabase.GUIDToAssetPath(newFolderID); | |
} | |
string assetPathAndName = assetPath + "/CU-"+_cue.GetName()+".asset"; | |
string uniqueAssetPathAndName = AssetDatabase.GenerateUniqueAssetPath(assetPathAndName); | |
AssetDatabase.CreateAsset(_cue,uniqueAssetPathAndName); | |
AssetDatabase.SaveAssets(); | |
AssetDatabase.Refresh(); | |
} | |
// ******************************************************************** | |
public void RemoveCue(Cue _cue) | |
{ | |
cues.Remove(_cue); | |
m_selectedCues.Remove(_cue); | |
_cue.series = null; | |
// Set dirty | |
EditorUtility.SetDirty(this); | |
string pathToDelete = AssetDatabase.GetAssetPath(_cue); | |
AssetDatabase.DeleteAsset(pathToDelete); | |
AssetDatabase.SaveAssets(); | |
AssetDatabase.Refresh(); | |
} | |
// ******************************************************************** | |
public void SelectCue(Cue _cue, bool _select) | |
{ | |
if (_select) | |
{ | |
m_selectedCues.AddWithoutDuplicates(_cue); | |
m_selectedCues.Sort( | |
delegate(Cue p1, Cue p2) | |
{ | |
return cues.IndexOf(p1).CompareTo(cues.IndexOf(p2)); | |
} | |
); | |
} | |
else | |
m_selectedCues.Remove(_cue); | |
} | |
// ******************************************************************** | |
public void MoveCue(Cue _cue, int _move) | |
{ | |
MoveCueTo(_cue, cues.IndexOf(_cue) + _move); | |
} | |
// ******************************************************************** | |
public void MoveCueTo(Cue _cue, int _index) | |
{ | |
if (_index < 0 || _index >= cues.Count) | |
return; | |
cues.Remove(_cue); | |
cues.Insert(_index,_cue); | |
// Set dirty | |
EditorUtility.SetDirty(this); | |
} | |
// ******************************************************************** | |
[ContextMenu("Duplicate")] | |
public void Duplicate() | |
{ | |
Debug.Log("Duplicate() CueSeries "+name+" started"); | |
// Create the asset and add it to the project | |
string parentFolder = Path.GetDirectoryName(AssetDatabase.GetAssetPath(this)); | |
string assetPath = parentFolder+"/Duplicant"; | |
if (!AssetDatabase.IsValidFolder(assetPath)) | |
{ | |
string newFolderID = AssetDatabase.CreateFolder(parentFolder,"Duplicant"); | |
assetPath = AssetDatabase.GUIDToAssetPath(newFolderID); | |
} | |
else | |
{ | |
Debug.LogError("Duplicant folder already exists - move or delete it to duplicate again."); | |
return; | |
} | |
EditorUtility.SetDirty(this); | |
// Create a new CueSeries | |
CueSeries duplicateSeries = ScriptableObject.CreateInstance<CueSeries>(); | |
duplicateSeries.name = name; | |
string assetPathAndName = assetPath + "/"+name+".asset"; | |
string uniqueAssetPathAndName = AssetDatabase.GenerateUniqueAssetPath(assetPathAndName); | |
AssetDatabase.CreateAsset(duplicateSeries,uniqueAssetPathAndName); | |
// Create the cue folder and add it to the project | |
assetPath = parentFolder+"/Duplicant"+"/Cues"; | |
if (!AssetDatabase.IsValidFolder(assetPath)) | |
{ | |
string newFolderID = AssetDatabase.CreateFolder(parentFolder+"/Duplicant","Cues"); | |
assetPath = AssetDatabase.GUIDToAssetPath(newFolderID); | |
} | |
// Create all the cues and add them to the new cue series | |
for (int i = 0; i < cues.Count; ++i) | |
{ | |
Cue duplicateCue = Instantiate(cues[i]); | |
//duplicateSeries.AddCue(duplicateCue); | |
// Add cue | |
duplicateSeries.cues.Add(duplicateCue); | |
duplicateCue.series = duplicateSeries; | |
// Set dirty | |
assetPathAndName = parentFolder+"/Duplicant"+"/Cues" + "/CU-"+duplicateCue.GetName()+".asset"; | |
uniqueAssetPathAndName = AssetDatabase.GenerateUniqueAssetPath(assetPathAndName); | |
AssetDatabase.CreateAsset(duplicateCue,uniqueAssetPathAndName); | |
} | |
AssetDatabase.SaveAssets(); | |
AssetDatabase.Refresh(); | |
Debug.Log("Duplicate() CueSeries "+name+" completed"); | |
} | |
// ******************************************************************** | |
[CustomEditor(typeof(CueSeries))] | |
public class Inspector : Editor | |
{ | |
// **************************************************************** | |
#region Private Data Members | |
// **************************************************************** | |
private PreviewRenderUtility m_previewRenderUtility; | |
private static List<Cue> s_coppiedCues = new List<Cue>(); | |
private float m_timeShiftAmount = 0; | |
// **************************************************************** | |
#endregion | |
// **************************************************************** | |
// **************************************************************** | |
#region Editor Methods | |
// **************************************************************** | |
public override void OnInspectorGUI() | |
{ | |
CueSeries series = (CueSeries)target; | |
if (!series.cues.Empty()) | |
DrawButtons(); | |
for (int i = 0; i < series.cues.Count; ++i) | |
{ | |
if (series.cues[i] != null) | |
{ | |
series.cues[i].DrawUI(); | |
} | |
} | |
DrawButtons(series.cues.Empty()); // only show the + sign if the cue series is empty | |
OnPreviewGUI(GUILayoutUtility.GetRect(500,500),EditorStyles.whiteLabel); | |
} | |
// **************************************************************** | |
private void DrawButtons(bool _emptySeries = false) | |
{ | |
CueSeries series = (CueSeries)target; | |
GUIStyle style = new GUIStyle(); | |
style.alignment = TextAnchor.MiddleRight; | |
Color oldColor = GUI.backgroundColor; | |
EditorGUILayout.BeginHorizontal(style); | |
if (!_emptySeries) | |
{ | |
m_timeShiftAmount = EditorGUILayout.FloatField("", m_timeShiftAmount,GUILayout.Width(50)); | |
GUI.backgroundColor = oldColor; | |
if (GUILayout.Button("Shift",GUILayout.Width(50))) | |
{ | |
for (int i = 0; i < series.selectedCues.Count; ++i) | |
{ | |
series.selectedCues[i].enterTime += m_timeShiftAmount; | |
if (series.selectedCues[i].exitTime != 0) // exit time of zero means no exit! | |
series.selectedCues[i].exitTime += m_timeShiftAmount; | |
EditorUtility.SetDirty(series.selectedCues[i]); | |
} | |
} | |
} | |
// go to right side | |
GUILayout.FlexibleSpace(); | |
// Duplicate button | |
GUI.backgroundColor = oldColor; | |
if (!_emptySeries && GUILayout.Button("Sort",GUILayout.Width(50))) | |
{ | |
SortSeries(); | |
} | |
// Duplicate button | |
GUI.backgroundColor = Color.green; | |
if (!_emptySeries && GUILayout.Button("Copy",GUILayout.Width(50))) | |
{ | |
CopySelectedCues(); | |
} | |
// Duplicate button | |
GUI.backgroundColor = Color.green; | |
if (GUILayout.Button("Paste",GUILayout.Width(50))) | |
{ | |
PasteSelectedCues(); | |
} | |
// Duplicate button | |
GUI.backgroundColor = Color.green; | |
if (!_emptySeries && GUILayout.Button("Duplicate",GUILayout.Width(80))) | |
{ | |
series.Duplicate(); | |
} | |
// Add cue button | |
GUI.backgroundColor = Color.green; | |
if (_emptySeries && GUILayout.Button("+",GUILayout.Width(30))) | |
{ | |
OpenAddCueMenu(); | |
} | |
// New line | |
EditorGUILayout.EndHorizontal(); | |
if (!_emptySeries) | |
{ | |
EditorGUILayout.BeginHorizontal(style); | |
// Duplicate button | |
GUI.backgroundColor = Color.green; | |
if (GUILayout.Button("All",GUILayout.Width(50))) | |
{ | |
SelectAll(); | |
} | |
// Duplicate button | |
GUI.backgroundColor = Color.red; | |
if (GUILayout.Button("None",GUILayout.Width(50))) | |
{ | |
SelectNone(); | |
} | |
// go to right side | |
GUILayout.FlexibleSpace(); | |
// the same options that are on individual cues | |
GUI.backgroundColor = Color.green; | |
if (GUILayout.Button("=",GUILayout.Width(25))) | |
{ | |
for (int i = 0; i < series.selectedCues.Count; ++i) | |
{ | |
Cue newCue = CreateInstance(series.selectedCues[i].GetType()) as Cue; | |
EditorUtility.CopySerialized(series.selectedCues[i],newCue); | |
series.AddCueAt(newCue,series.cues.IndexOf(series.selectedCues[i])+1); | |
} | |
} | |
GUI.backgroundColor = oldColor; | |
if (GUILayout.Button("/\\",GUILayout.Width(25))) | |
{ | |
// Start at the top and work downward | |
for (int i = 0; i < series.selectedCues.Count; ++i) | |
{ | |
series.MoveCue(series.selectedCues[i],-1); | |
} | |
} | |
GUI.backgroundColor = oldColor; | |
if (GUILayout.Button("\\/",GUILayout.Width(25))) | |
{ | |
// Start at the bottom and work upward | |
for (int i = series.selectedCues.Count-1; i >= 0; --i) | |
{ | |
series.MoveCue(series.selectedCues[i],+1); | |
} | |
} | |
GUI.backgroundColor = oldColor; | |
if (GUILayout.Button("/|\\",GUILayout.Width(25))) | |
{ | |
// Start at the bottom and work upward | |
for (int i = series.selectedCues.Count-1; i >= 0; --i) | |
{ | |
series.MoveCueTo(series.selectedCues[i],0); | |
} | |
} | |
GUI.backgroundColor = oldColor; | |
if (GUILayout.Button("\\|/",GUILayout.Width(25))) | |
{ | |
// Start at the top and work downward | |
for (int i = 0; i < series.selectedCues.Count; ++i) | |
{ | |
series.MoveCueTo(series.selectedCues[i],series.cues.Count-1); | |
} | |
} | |
GUI.backgroundColor = Color.red; | |
if (GUILayout.Button("X",GUILayout.Width(25))) | |
{ | |
// Start at the bottom and work upward, as this will modify the list | |
for (int i = series.selectedCues.Count-1; i >= 0; --i) | |
{ | |
series.selectedCues[i].RemoveCue(); | |
} | |
return; // Return early to avoid argument out of range issues while deleting while iterating. | |
} | |
EditorGUILayout.EndHorizontal(); | |
} | |
GUI.backgroundColor = oldColor; | |
} | |
// **************************************************************** | |
private void SelectAll() | |
{ | |
CueSeries series = (CueSeries)target; | |
for (int i = 0; i < series.cues.Count; ++i) | |
{ | |
series.cues[i].selected = true; | |
} | |
} | |
// **************************************************************** | |
private void SelectNone() | |
{ | |
CueSeries series = (CueSeries)target; | |
// Walk backwards since changing selected will modify the list | |
for (int i = series.selectedCues.Count-1; i >= 0; --i) | |
{ | |
series.selectedCues[i].selected = false; | |
} | |
} | |
// **************************************************************** | |
private void CopySelectedCues() | |
{ | |
CueSeries series = (CueSeries)target; | |
s_coppiedCues.Clear(); | |
s_coppiedCues = series.selectedCues.Copy(); | |
} | |
// **************************************************************** | |
private void PasteSelectedCues() | |
{ | |
CueSeries series = (CueSeries)target; | |
for (int i = 0; i < s_coppiedCues.Count; ++i) | |
{ | |
Cue duplicateCue = Instantiate(s_coppiedCues[i]); | |
series.AddCue(duplicateCue); | |
} | |
EditorUtility.SetDirty(series); | |
} | |
// **************************************************************** | |
private void SortSeries() | |
{ | |
CueSeries series = (CueSeries)target; | |
List<Cue> sortedCues = series.cues.Copy(); | |
sortedCues.Sort( | |
delegate(Cue p1, Cue p2) { | |
if (p1 == null || p2 == null) | |
return 0; | |
if (p1.enterTime != p2.enterTime) | |
return p1.enterTime.CompareTo(p2.enterTime); | |
else | |
return series.cues.IndexOf(p1).CompareTo(series.cues.IndexOf(p2)); | |
} | |
); | |
series.cues = sortedCues; | |
EditorUtility.SetDirty(series); | |
} | |
// **************************************************************** | |
private void OpenAddCueMenu() | |
{ | |
CueSeries series = (CueSeries)target; | |
// Open add cue menu | |
Vector2 mousePos = Event.current.mousePosition; | |
GenericMenu addCueMenu = new GenericMenu(); | |
Type[] cueTypes = typeof(Cue).GetSubTypes(); | |
for (int i = 0; i < cueTypes.Length; ++i) | |
{ | |
bool allow = (bool) cueTypes[i].GetField("allowCreation").GetValue(null); | |
if (allow) | |
{ | |
string menuName = (string) cueTypes[i].GetField("menuName").GetValue(null); | |
addCueMenu.AddItem(new GUIContent(menuName),false,series.AddCue,cueTypes[i]); | |
} | |
} | |
addCueMenu.DropDown(new Rect(mousePos.x,mousePos.y,0,16)); | |
} | |
// **************************************************************** | |
public override bool HasPreviewGUI() | |
{ | |
return true; | |
} | |
// **************************************************************** | |
public override void OnPreviewGUI(Rect _overallRect, GUIStyle _backgroundStyle) | |
{ | |
if (_backgroundStyle != null && _backgroundStyle.name == "PreBackground") | |
{ | |
// Timeline | |
Rect timelineRect = _overallRect; | |
timelineRect.x += 5; | |
timelineRect.y += 5; | |
timelineRect.height = 20; | |
timelineRect.width -= 10; | |
EditorGUI.DrawRect(timelineRect,Color.white); | |
// Copy and sort cues | |
CueSeries series = (CueSeries)target; | |
List<Cue> sortedCues = series.cues.Copy(); | |
sortedCues.Sort( | |
delegate(Cue p1, Cue p2) { | |
if (p1 == null || p2 == null) | |
return 0; | |
if (p1.enterTime != p2.enterTime) | |
return p1.enterTime.CompareTo(p2.enterTime); | |
else | |
return series.cues.IndexOf(p1).CompareTo(series.cues.IndexOf(p2)); | |
} | |
); | |
// Calculate timeline slices | |
float finalTime = series.GetFinalTime(); | |
int timelineSlices = 1; | |
float timelineSliceValue = 1.0f; | |
float minTimelineSliceWidth = 40; | |
float timelineSliceWidth = minTimelineSliceWidth; | |
int maxTimelineSlices = Mathf.FloorToInt(timelineRect.width / minTimelineSliceWidth); | |
if (finalTime != 0) | |
{ | |
timelineSlices = Mathf.CeilToInt(finalTime / timelineSliceValue); | |
if (timelineSlices > maxTimelineSlices) | |
{ | |
timelineSlices = maxTimelineSlices; | |
} | |
timelineSliceValue = finalTime / (float) timelineSlices; | |
timelineSliceWidth = timelineRect.width / (float) (timelineSlices); | |
} | |
// Print cues | |
float pixelsPerSecond = timelineSliceWidth / timelineSliceValue; | |
float minCueWidth = 10; | |
float minCueSeconds = minCueWidth / pixelsPerSecond; | |
List<List<Cue> > layers = new List<List<Cue> >(); | |
for (int i = 0; i < sortedCues.Count; ++i) | |
{ | |
Cue thisCue = sortedCues[i]; | |
if (thisCue == null) | |
continue; | |
//find a layer for this cue | |
int layer = layers.Count; | |
for (int j = 0; j < layers.Count; ++j) | |
{ | |
Cue lastCue = layers[j].Back(); | |
float exitTime = lastCue.exitTime == 0 ? lastCue.enterTime : lastCue.exitTime; | |
if (lastCue.duration < minCueSeconds) | |
exitTime = lastCue.enterTime + minCueSeconds; | |
if (exitTime <= thisCue.enterTime) | |
{ | |
layer = j; | |
break; | |
} | |
} | |
if (layer == layers.Count) | |
{ | |
layers.Add(new List<Cue>()); | |
} | |
layers[layer].Add(sortedCues[i]); | |
// Draw the cue | |
float cueWidth = minCueWidth; | |
if (thisCue.exitTime != 0 && thisCue.exitTime - thisCue.enterTime > minCueSeconds) | |
cueWidth = pixelsPerSecond * (thisCue.exitTime - thisCue.enterTime); | |
float cueStart = pixelsPerSecond*thisCue.enterTime; | |
if (cueStart + cueWidth > timelineRect.width) | |
cueStart = timelineRect.width - cueWidth; | |
Rect cueRect = new Rect(timelineRect.x + cueStart, | |
timelineRect.y + 25+layer*20, | |
cueWidth, | |
20); | |
EditorGUI.DrawRect(cueRect,thisCue.GetCueColor()); | |
EditorGUI.LabelField(cueRect,thisCue.name); | |
Vector2 mousePos = Event.current.mousePosition; | |
if (cueRect.Contains(mousePos)) | |
{ | |
GenericMenu addCueMenu = new GenericMenu(); | |
addCueMenu.AddDisabledItem(new GUIContent(thisCue.name)); | |
addCueMenu.DropDown(new Rect(mousePos.x,mousePos.y,0,16)); | |
} | |
} | |
// Print timeline slices | |
for (int i = 0; i <= timelineSlices; ++i) | |
{ | |
float labelValue = (timelineSliceValue * (float)i); | |
float xPos = timelineRect.x + timelineSliceWidth * (float)i; | |
EditorGUI.DrawRect(new Rect(xPos,timelineRect.y,1,25+layers.Count*20),Color.gray); | |
if (i < timelineSlices) | |
EditorGUI.LabelField(new Rect(xPos,timelineRect.y,timelineSliceWidth,20),string.Format("{0:F1}",labelValue)); | |
} | |
} | |
} | |
// **************************************************************** | |
#endregion | |
// **************************************************************** | |
} | |
// ******************************************************************** | |
#endif | |
// ******************************************************************** | |
} | |
#endregion | |
// ************************************************************************ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment