2019-01-24 16:43:03 +08:00
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
2018-11-16 16:12:24 +08:00
2020-09-30 13:41:32 +08:00
using System.Collections.Generic ;
2018-11-16 16:12:24 +08:00
using System.Linq ;
2022-01-07 22:11:38 +08:00
using osu.Framework.Allocation ;
2020-09-29 18:43:50 +08:00
using osu.Framework.Graphics ;
2020-09-29 18:50:03 +08:00
using osu.Framework.Graphics.Primitives ;
2022-08-12 07:17:33 +08:00
using osu.Framework.Graphics.UserInterface ;
2022-08-15 23:18:55 +08:00
using osu.Framework.Input.Events ;
2020-09-30 12:02:05 +08:00
using osu.Framework.Utils ;
2021-04-29 14:29:25 +08:00
using osu.Game.Extensions ;
2022-08-12 07:17:33 +08:00
using osu.Game.Graphics.UserInterface ;
2022-01-07 22:11:38 +08:00
using osu.Game.Rulesets.Edit ;
2020-10-09 05:32:33 +08:00
using osu.Game.Rulesets.Objects ;
2020-09-29 19:00:19 +08:00
using osu.Game.Rulesets.Objects.Types ;
2018-11-16 16:12:24 +08:00
using osu.Game.Rulesets.Osu.Objects ;
2022-01-05 15:46:34 +08:00
using osu.Game.Rulesets.Osu.UI ;
2018-11-16 16:12:24 +08:00
using osu.Game.Screens.Edit.Compose.Components ;
2023-07-24 00:24:15 +08:00
using osu.Game.Utils ;
2021-05-20 17:21:16 +08:00
using osuTK ;
2022-08-15 23:18:55 +08:00
using osuTK.Input ;
2018-11-16 16:12:24 +08:00
namespace osu.Game.Rulesets.Osu.Edit
{
2022-11-24 13:32:20 +08:00
public partial class OsuSelectionHandler : EditorSelectionHandler
2018-11-16 16:12:24 +08:00
{
2022-01-07 22:11:38 +08:00
[Resolved(CanBeNull = true)]
2022-04-28 10:48:45 +08:00
private IDistanceSnapProvider ? snapProvider { get ; set ; }
2022-01-07 22:11:38 +08:00
2021-05-18 17:34:06 +08:00
/// <summary>
/// During a transform, the initial path types of a single selected slider are stored so they
/// can be maintained throughout the operation.
/// </summary>
2022-01-07 22:11:38 +08:00
private List < PathType ? > ? referencePathTypes ;
2021-05-18 17:34:06 +08:00
2020-09-30 12:52:57 +08:00
protected override void OnSelectionChanged ( )
{
base . OnSelectionChanged ( ) ;
2020-09-29 18:43:50 +08:00
2023-07-24 00:24:15 +08:00
Quad quad = selectedMovableObjects . Length > 0 ? GeometryUtils . GetSurroundingQuad ( selectedMovableObjects ) : new Quad ( ) ;
2020-09-30 12:52:57 +08:00
2021-07-21 14:59:25 +08:00
SelectionBox . CanFlipX = SelectionBox . CanScaleX = quad . Width > 0 ;
SelectionBox . CanFlipY = SelectionBox . CanScaleY = quad . Height > 0 ;
2020-11-13 06:19:29 +08:00
SelectionBox . CanReverse = EditorBeatmap . SelectedHitObjects . Count > 1 | | EditorBeatmap . SelectedHitObjects . Any ( s = > s is Slider ) ;
2020-09-30 12:52:57 +08:00
}
2020-09-29 19:08:28 +08:00
2020-10-01 15:24:04 +08:00
protected override void OnOperationEnded ( )
2020-09-30 12:52:57 +08:00
{
2020-10-01 15:24:04 +08:00
base . OnOperationEnded ( ) ;
2021-03-29 21:49:49 +08:00
referencePathTypes = null ;
2020-09-30 12:52:57 +08:00
}
2020-09-29 18:43:50 +08:00
2022-08-15 23:18:55 +08:00
protected override bool OnKeyDown ( KeyDownEvent e )
{
if ( e . Key = = Key . M & & e . ControlPressed & & e . ShiftPressed )
{
mergeSelection ( ) ;
return true ;
}
return false ;
}
2021-04-27 14:40:35 +08:00
public override bool HandleMovement ( MoveSelectionEvent < HitObject > moveEvent )
2021-02-22 00:38:50 +08:00
{
2021-02-24 03:58:46 +08:00
var hitObjects = selectedMovableObjects ;
2021-03-30 13:13:16 +08:00
// this will potentially move the selection out of bounds...
2021-02-24 03:58:46 +08:00
foreach ( var h in hitObjects )
2021-04-29 14:29:25 +08:00
h . Position + = this . ScreenSpaceDeltaToParentSpace ( moveEvent . ScreenSpaceDelta ) ;
2021-02-24 03:58:46 +08:00
2021-03-30 13:13:16 +08:00
// but this will be corrected.
2021-02-22 00:38:50 +08:00
moveSelectionInBounds ( ) ;
2021-02-24 03:58:46 +08:00
return true ;
2021-02-22 00:38:50 +08:00
}
2020-09-29 19:08:28 +08:00
2020-10-09 05:32:33 +08:00
public override bool HandleReverse ( )
{
2020-11-13 07:36:47 +08:00
var hitObjects = EditorBeatmap . SelectedHitObjects ;
2020-10-09 05:32:33 +08:00
double endTime = hitObjects . Max ( h = > h . GetEndTime ( ) ) ;
double startTime = hitObjects . Min ( h = > h . StartTime ) ;
2020-11-13 07:36:47 +08:00
bool moreThanOneObject = hitObjects . Count > 1 ;
2020-10-09 05:32:33 +08:00
foreach ( var h in hitObjects )
{
if ( moreThanOneObject )
h . StartTime = endTime - ( h . GetEndTime ( ) - startTime ) ;
if ( h is Slider slider )
{
2021-07-22 15:14:43 +08:00
slider . Path . Reverse ( out Vector2 offset ) ;
slider . Position + = offset ;
2020-10-09 05:32:33 +08:00
}
}
return true ;
}
2022-01-05 15:46:34 +08:00
public override bool HandleFlip ( Direction direction , bool flipOverOrigin )
2020-10-01 15:24:50 +08:00
{
var hitObjects = selectedMovableObjects ;
2023-07-24 00:24:15 +08:00
var flipQuad = flipOverOrigin ? new Quad ( 0 , 0 , OsuPlayfield . BASE_SIZE . X , OsuPlayfield . BASE_SIZE . Y ) : GeometryUtils . GetSurroundingQuad ( hitObjects ) ;
2020-10-01 15:24:50 +08:00
2022-01-06 13:37:13 +08:00
bool didFlip = false ;
2020-10-01 15:24:50 +08:00
foreach ( var h in hitObjects )
{
2023-07-24 00:24:15 +08:00
var flippedPosition = GeometryUtils . GetFlippedPosition ( direction , flipQuad , h . Position ) ;
2022-01-06 13:37:13 +08:00
if ( ! Precision . AlmostEquals ( flippedPosition , h . Position ) )
{
h . Position = flippedPosition ;
didFlip = true ;
}
2020-10-01 15:24:50 +08:00
if ( h is Slider slider )
{
2022-01-06 13:37:13 +08:00
didFlip = true ;
2022-09-15 18:55:18 +08:00
foreach ( var cp in slider . Path . ControlPoints )
{
cp . Position = new Vector2 (
( direction = = Direction . Horizontal ? - 1 : 1 ) * cp . Position . X ,
( direction = = Direction . Vertical ? - 1 : 1 ) * cp . Position . Y
) ;
}
2020-10-01 15:24:50 +08:00
}
}
2022-01-06 13:37:13 +08:00
return didFlip ;
2020-10-01 15:24:50 +08:00
}
2020-09-30 14:08:56 +08:00
public override bool HandleScale ( Vector2 scale , Anchor reference )
2020-09-29 19:00:19 +08:00
{
2020-09-30 14:17:27 +08:00
adjustScaleFromAnchor ( ref scale , reference ) ;
2020-09-29 19:00:19 +08:00
2020-09-30 13:41:32 +08:00
var hitObjects = selectedMovableObjects ;
2020-09-29 18:43:50 +08:00
2020-09-30 13:41:32 +08:00
// for the time being, allow resizing of slider paths only if the slider is
// the only hit object selected. with a group selection, it's likely the user
// is not looking to change the duration of the slider but expand the whole pattern.
if ( hitObjects . Length = = 1 & & hitObjects . First ( ) is Slider slider )
2021-03-26 23:39:13 +08:00
scaleSlider ( slider , scale ) ;
2020-09-30 13:41:32 +08:00
else
2021-03-26 23:39:13 +08:00
scaleHitObjects ( hitObjects , reference , scale ) ;
2020-09-29 18:43:50 +08:00
2021-02-21 04:50:30 +08:00
moveSelectionInBounds ( ) ;
2020-09-29 18:43:50 +08:00
return true ;
}
2020-09-30 14:17:27 +08:00
private static void adjustScaleFromAnchor ( ref Vector2 scale , Anchor reference )
{
// cancel out scale in axes we don't care about (based on which drag handle was used).
if ( ( reference & Anchor . x1 ) > 0 ) scale . X = 0 ;
if ( ( reference & Anchor . y1 ) > 0 ) scale . Y = 0 ;
// reverse the scale direction if dragging from top or left.
if ( ( reference & Anchor . x0 ) > 0 ) scale . X = - scale . X ;
if ( ( reference & Anchor . y0 ) > 0 ) scale . Y = - scale . Y ;
}
2023-07-31 01:39:30 +08:00
public override SelectionRotationHandler CreateRotationHandler ( ) = > new OsuSelectionRotationHandler ( ) ;
2020-09-30 14:08:56 +08:00
2021-03-26 23:39:13 +08:00
private void scaleSlider ( Slider slider , Vector2 scale )
2021-02-21 19:12:32 +08:00
{
2021-08-26 00:42:57 +08:00
referencePathTypes ? ? = slider . Path . ControlPoints . Select ( p = > p . Type ) . ToList ( ) ;
2021-04-01 23:09:45 +08:00
2023-07-24 00:24:15 +08:00
Quad sliderQuad = GeometryUtils . GetSurroundingQuad ( slider . Path . ControlPoints . Select ( p = > p . Position ) ) ;
2021-03-29 20:02:53 +08:00
2021-04-16 15:55:24 +08:00
// Limit minimum distance between control points after scaling to almost 0. Less than 0 causes the slider to flip, exactly 0 causes a crash through division by 0.
2021-03-29 20:02:53 +08:00
scale = Vector2 . ComponentMax ( new Vector2 ( Precision . FLOAT_EPSILON ) , sliderQuad . Size + scale ) - sliderQuad . Size ;
2021-04-16 15:55:24 +08:00
Vector2 pathRelativeDeltaScale = new Vector2 (
sliderQuad . Width = = 0 ? 0 : 1 + scale . X / sliderQuad . Width ,
sliderQuad . Height = = 0 ? 0 : 1 + scale . Y / sliderQuad . Height ) ;
2021-02-22 00:40:57 +08:00
2021-03-26 23:28:04 +08:00
Queue < Vector2 > oldControlPoints = new Queue < Vector2 > ( ) ;
2021-03-23 23:09:44 +08:00
foreach ( var point in slider . Path . ControlPoints )
2021-03-26 23:28:04 +08:00
{
2021-08-26 00:42:57 +08:00
oldControlPoints . Enqueue ( point . Position ) ;
point . Position * = pathRelativeDeltaScale ;
2021-03-26 23:28:04 +08:00
}
2021-02-22 00:40:57 +08:00
2021-04-01 23:09:45 +08:00
// Maintain the path types in case they were defaulted to bezier at some point during scaling
for ( int i = 0 ; i < slider . Path . ControlPoints . Count ; + + i )
2021-08-26 00:42:57 +08:00
slider . Path . ControlPoints [ i ] . Type = referencePathTypes [ i ] ;
2021-04-01 23:09:45 +08:00
2022-01-07 22:11:38 +08:00
// Snap the slider's length to the current beat divisor
// to calculate the final resulting duration / bounding box before the final checks.
2022-04-28 10:48:45 +08:00
slider . SnapTo ( snapProvider ) ;
2022-01-07 22:11:38 +08:00
2021-03-23 23:09:44 +08:00
//if sliderhead or sliderend end up outside playfield, revert scaling.
2023-07-24 00:24:15 +08:00
Quad scaledQuad = GeometryUtils . GetSurroundingQuad ( new OsuHitObject [ ] { slider } ) ;
2021-03-23 23:09:44 +08:00
( bool xInBounds , bool yInBounds ) = isQuadInBounds ( scaledQuad ) ;
2021-02-22 00:40:57 +08:00
2021-04-16 14:23:27 +08:00
if ( xInBounds & & yInBounds & & slider . Path . HasValidLength )
2021-03-26 23:39:13 +08:00
return ;
2021-02-21 19:12:32 +08:00
2021-03-27 00:41:36 +08:00
foreach ( var point in slider . Path . ControlPoints )
2021-08-26 00:42:57 +08:00
point . Position = oldControlPoints . Dequeue ( ) ;
2022-01-07 22:11:38 +08:00
// Snap the slider's length again to undo the potentially-invalid length applied by the previous snap.
2022-04-28 10:48:45 +08:00
slider . SnapTo ( snapProvider ) ;
2021-02-21 19:12:32 +08:00
}
2021-03-26 23:39:13 +08:00
private void scaleHitObjects ( OsuHitObject [ ] hitObjects , Anchor reference , Vector2 scale )
2021-02-21 19:12:32 +08:00
{
2021-03-23 19:41:43 +08:00
scale = getClampedScale ( hitObjects , reference , scale ) ;
2023-07-24 00:24:15 +08:00
Quad selectionQuad = GeometryUtils . GetSurroundingQuad ( hitObjects ) ;
2021-02-21 19:12:32 +08:00
foreach ( var h in hitObjects )
2023-07-24 00:24:15 +08:00
h . Position = GeometryUtils . GetScaledPosition ( reference , scale , selectionQuad , h . Position ) ;
2021-02-21 19:12:32 +08:00
}
2020-09-30 14:08:56 +08:00
2021-02-22 00:40:57 +08:00
private ( bool X , bool Y ) isQuadInBounds ( Quad quad )
{
2021-03-24 00:21:42 +08:00
bool xInBounds = ( quad . TopLeft . X > = 0 ) & & ( quad . BottomRight . X < = DrawWidth ) ;
bool yInBounds = ( quad . TopLeft . Y > = 0 ) & & ( quad . BottomRight . Y < = DrawHeight ) ;
2021-02-22 00:40:57 +08:00
2021-02-23 07:25:40 +08:00
return ( xInBounds , yInBounds ) ;
2021-02-22 00:40:57 +08:00
}
2021-02-21 04:48:31 +08:00
private void moveSelectionInBounds ( )
2018-11-16 16:12:24 +08:00
{
2020-09-30 13:41:32 +08:00
var hitObjects = selectedMovableObjects ;
2019-11-06 16:27:41 +08:00
2023-07-24 00:24:15 +08:00
Quad quad = GeometryUtils . GetSurroundingQuad ( hitObjects ) ;
2019-11-06 16:27:41 +08:00
2021-03-30 13:13:16 +08:00
Vector2 delta = Vector2 . Zero ;
2019-11-06 16:27:41 +08:00
2021-02-21 04:48:31 +08:00
if ( quad . TopLeft . X < 0 )
delta . X - = quad . TopLeft . X ;
if ( quad . TopLeft . Y < 0 )
delta . Y - = quad . TopLeft . Y ;
2020-11-15 21:22:46 +08:00
2021-02-21 04:48:31 +08:00
if ( quad . BottomRight . X > DrawWidth )
delta . X - = quad . BottomRight . X - DrawWidth ;
if ( quad . BottomRight . Y > DrawHeight )
delta . Y - = quad . BottomRight . Y - DrawHeight ;
2019-11-06 16:27:41 +08:00
2020-09-30 13:41:32 +08:00
foreach ( var h in hitObjects )
2020-09-29 18:43:50 +08:00
h . Position + = delta ;
2018-11-16 16:12:24 +08:00
}
2018-11-26 15:08:56 +08:00
2021-03-23 19:41:43 +08:00
/// <summary>
2021-03-29 20:17:30 +08:00
/// Clamp scale for multi-object-scaling where selection does not exceed playfield bounds or flip.
2021-03-23 19:41:43 +08:00
/// </summary>
/// <param name="hitObjects">The hitobjects to be scaled</param>
/// <param name="reference">The anchor from which the scale operation is performed</param>
/// <param name="scale">The scale to be clamped</param>
/// <returns>The clamped scale vector</returns>
private Vector2 getClampedScale ( OsuHitObject [ ] hitObjects , Anchor reference , Vector2 scale )
{
float xOffset = ( ( reference & Anchor . x0 ) > 0 ) ? - scale . X : 0 ;
float yOffset = ( ( reference & Anchor . y0 ) > 0 ) ? - scale . Y : 0 ;
2023-07-24 00:24:15 +08:00
Quad selectionQuad = GeometryUtils . GetSurroundingQuad ( hitObjects ) ;
2021-03-23 19:41:43 +08:00
2021-03-26 23:45:05 +08:00
//todo: this is not always correct for selections involving sliders. This approximation assumes each point is scaled independently, but sliderends move with the sliderhead.
2021-03-23 19:41:43 +08:00
Quad scaledQuad = new Quad ( selectionQuad . TopLeft . X + xOffset , selectionQuad . TopLeft . Y + yOffset , selectionQuad . Width + scale . X , selectionQuad . Height + scale . Y ) ;
//max Size -> playfield bounds
if ( scaledQuad . TopLeft . X < 0 )
scale . X + = scaledQuad . TopLeft . X ;
if ( scaledQuad . TopLeft . Y < 0 )
scale . Y + = scaledQuad . TopLeft . Y ;
if ( scaledQuad . BottomRight . X > DrawWidth )
scale . X - = scaledQuad . BottomRight . X - DrawWidth ;
if ( scaledQuad . BottomRight . Y > DrawHeight )
scale . Y - = scaledQuad . BottomRight . Y - DrawHeight ;
//min Size -> almost 0. Less than 0 causes the quad to flip, exactly 0 causes scaling to get stuck at minimum scale.
Vector2 scaledSize = selectionQuad . Size + scale ;
Vector2 minSize = new Vector2 ( Precision . FLOAT_EPSILON ) ;
scale = Vector2 . ComponentMax ( minSize , scaledSize ) - selectionQuad . Size ;
return scale ;
2018-11-16 16:12:24 +08:00
}
2020-09-29 18:50:03 +08:00
2020-09-30 13:41:32 +08:00
/// <summary>
/// All osu! hitobjects which can be moved/rotated/scaled.
/// </summary>
2021-04-27 17:03:34 +08:00
private OsuHitObject [ ] selectedMovableObjects = > SelectedItems . OfType < OsuHitObject > ( )
2022-08-16 14:37:38 +08:00
. Where ( h = > h is not Spinner )
2020-10-09 17:50:05 +08:00
. ToArray ( ) ;
2022-08-12 07:17:33 +08:00
/// <summary>
/// All osu! hitobjects which can be merged.
/// </summary>
private OsuHitObject [ ] selectedMergeableObjects = > SelectedItems . OfType < OsuHitObject > ( )
. Where ( h = > h is HitCircle or Slider )
. OrderBy ( h = > h . StartTime )
. ToArray ( ) ;
private void mergeSelection ( )
{
2022-08-16 14:38:13 +08:00
var mergeableObjects = selectedMergeableObjects ;
2022-08-30 00:58:29 +08:00
if ( ! canMerge ( mergeableObjects ) )
2022-08-12 07:17:33 +08:00
return ;
2022-08-27 23:43:32 +08:00
EditorBeatmap . BeginChange ( ) ;
2022-08-12 07:17:33 +08:00
// Have an initial slider object.
2022-08-16 14:38:13 +08:00
var firstHitObject = mergeableObjects [ 0 ] ;
2022-08-12 07:17:33 +08:00
var mergedHitObject = firstHitObject as Slider ? ? new Slider
{
StartTime = firstHitObject . StartTime ,
Position = firstHitObject . Position ,
NewCombo = firstHitObject . NewCombo ,
2022-08-25 17:49:38 +08:00
Samples = firstHitObject . Samples ,
2022-08-12 07:17:33 +08:00
} ;
if ( mergedHitObject . Path . ControlPoints . Count = = 0 )
{
mergedHitObject . Path . ControlPoints . Add ( new PathControlPoint ( Vector2 . Zero , PathType . Linear ) ) ;
}
// Merge all the selected hit objects into one slider path.
bool lastCircle = firstHitObject is HitCircle ;
2022-08-16 14:38:13 +08:00
foreach ( var selectedMergeableObject in mergeableObjects . Skip ( 1 ) )
2022-08-12 07:17:33 +08:00
{
if ( selectedMergeableObject is IHasPath hasPath )
{
var offset = lastCircle ? selectedMergeableObject . Position - mergedHitObject . Position : mergedHitObject . Path . ControlPoints [ ^ 1 ] . Position ;
float distanceToLastControlPoint = Vector2 . Distance ( mergedHitObject . Path . ControlPoints [ ^ 1 ] . Position , offset ) ;
// Calculate the distance required to travel to the expected distance of the merging slider.
mergedHitObject . Path . ExpectedDistance . Value = mergedHitObject . Path . CalculatedDistance + distanceToLastControlPoint + hasPath . Path . Distance ;
// Remove the last control point if it sits exactly on the start of the next control point.
if ( Precision . AlmostEquals ( distanceToLastControlPoint , 0 ) )
{
mergedHitObject . Path . ControlPoints . RemoveAt ( mergedHitObject . Path . ControlPoints . Count - 1 ) ;
}
mergedHitObject . Path . ControlPoints . AddRange ( hasPath . Path . ControlPoints . Select ( o = > new PathControlPoint ( o . Position + offset , o . Type ) ) ) ;
lastCircle = false ;
}
else
{
// Turn the last control point into a linear type if this is the first merging circle in a sequence, so the subsequent control points can be inherited path type.
if ( ! lastCircle )
{
mergedHitObject . Path . ControlPoints . Last ( ) . Type = PathType . Linear ;
}
mergedHitObject . Path . ControlPoints . Add ( new PathControlPoint ( selectedMergeableObject . Position - mergedHitObject . Position ) ) ;
mergedHitObject . Path . ExpectedDistance . Value = null ;
lastCircle = true ;
}
}
// Make sure only the merged hit object is in the beatmap.
if ( firstHitObject is Slider )
{
2022-08-16 14:38:13 +08:00
foreach ( var selectedMergeableObject in mergeableObjects . Skip ( 1 ) )
2022-08-12 07:17:33 +08:00
{
2022-08-16 14:35:32 +08:00
EditorBeatmap . Remove ( selectedMergeableObject ) ;
2022-08-12 07:17:33 +08:00
}
}
else
{
2022-08-16 14:38:13 +08:00
foreach ( var selectedMergeableObject in mergeableObjects )
2022-08-12 07:17:33 +08:00
{
2022-08-16 14:35:32 +08:00
EditorBeatmap . Remove ( selectedMergeableObject ) ;
2022-08-12 07:17:33 +08:00
}
2022-08-16 14:35:32 +08:00
EditorBeatmap . Add ( mergedHitObject ) ;
2022-08-12 07:17:33 +08:00
}
// Make sure the merged hitobject is selected.
SelectedItems . Clear ( ) ;
SelectedItems . Add ( mergedHitObject ) ;
2022-08-27 23:43:32 +08:00
EditorBeatmap . EndChange ( ) ;
2022-08-12 07:17:33 +08:00
}
protected override IEnumerable < MenuItem > GetContextMenuItemsForSelection ( IEnumerable < SelectionBlueprint < HitObject > > selection )
{
foreach ( var item in base . GetContextMenuItemsForSelection ( selection ) )
yield return item ;
2022-08-30 00:58:29 +08:00
if ( canMerge ( selectedMergeableObjects ) )
2022-08-12 07:17:33 +08:00
yield return new OsuMenuItem ( "Merge selection" , MenuItemType . Destructive , mergeSelection ) ;
}
2022-08-30 01:51:42 +08:00
private bool canMerge ( IReadOnlyList < OsuHitObject > objects ) = >
objects . Count > 1
& & ( objects . Any ( h = > h is Slider )
2022-08-30 06:18:55 +08:00
| | objects . Zip ( objects . Skip ( 1 ) , ( h1 , h2 ) = > Precision . DefinitelyBigger ( Vector2 . DistanceSquared ( h1 . Position , h2 . Position ) , 1 ) ) . Any ( x = > x ) ) ;
2018-11-16 16:12:24 +08:00
}
}