blob: f2006bac0b8680853475e0259fecdf9e4c69bfe0 [file] [log] [blame]
Florin Malita094ccde2017-12-30 12:27:00 -05001/*
2 * Copyright 2017 Google Inc.
3 *
4 * Use of this source code is governed by a BSD-style license that can be
5 * found in the LICENSE file.
6 */
7
8#include "Skotty.h"
9
10#include "SkCanvas.h"
11#include "SkottyAnimator.h"
12#include "SkottyPriv.h"
13#include "SkottyProperties.h"
14#include "SkData.h"
15#include "SkMakeUnique.h"
16#include "SkPaint.h"
17#include "SkPath.h"
18#include "SkPoint.h"
19#include "SkSGColor.h"
20#include "SkSGDraw.h"
21#include "SkSGInvalidationController.h"
22#include "SkSGGroup.h"
23#include "SkSGPath.h"
Florin Malita2e1d7e22018-01-02 10:40:00 -050024#include "SkSGRect.h"
Florin Malita094ccde2017-12-30 12:27:00 -050025#include "SkSGTransform.h"
26#include "SkStream.h"
27#include "SkTArray.h"
28#include "SkTHash.h"
29
30#include <cmath>
31#include "stdlib.h"
32
33namespace skotty {
34
35namespace {
36
37using AssetMap = SkTHashMap<SkString, const Json::Value*>;
38
39struct AttachContext {
40 const AssetMap& fAssets;
41 SkTArray<std::unique_ptr<AnimatorBase>>& fAnimators;
42};
43
44bool LogFail(const Json::Value& json, const char* msg) {
45 const auto dump = json.toStyledString();
46 LOG("!! %s: %s", msg, dump.c_str());
47 return false;
48}
49
50// This is the workhorse for binding properties: depending on whether the property is animated,
51// it will either apply immediately or instantiate and attach a keyframe animator.
52template <typename ValueT, typename AttrT, typename NodeT, typename ApplyFuncT>
53bool AttachProperty(const Json::Value& jprop, AttachContext* ctx, const sk_sp<NodeT>& node,
54 ApplyFuncT&& apply) {
55 if (!jprop.isObject())
56 return false;
57
58 if (!ParseBool(jprop["a"], false)) {
59 // Static property.
60 ValueT val;
61 if (!ValueT::Parse(jprop["k"], &val)) {
62 return LogFail(jprop, "Could not parse static property");
63 }
64
65 apply(node, val.template as<AttrT>());
66 } else {
67 // Keyframe property.
68 using AnimatorT = Animator<ValueT, AttrT, NodeT>;
69 auto animator = AnimatorT::Make(jprop["k"], node, std::move(apply));
70
71 if (!animator) {
72 return LogFail(jprop, "Could not instantiate keyframe animator");
73 }
74
75 ctx->fAnimators.push_back(std::move(animator));
76 }
77
78 return true;
79}
80
81sk_sp<sksg::RenderNode> AttachTransform(const Json::Value& t, AttachContext* ctx,
82 sk_sp<sksg::RenderNode> wrapped_node) {
Florin Malita2e1d7e22018-01-02 10:40:00 -050083 if (!t.isObject() || !wrapped_node)
Florin Malita094ccde2017-12-30 12:27:00 -050084 return wrapped_node;
85
86 auto xform = sk_make_sp<CompositeTransform>(wrapped_node);
87 auto anchor_attached = AttachProperty<VectorValue, SkPoint>(t["a"], ctx, xform,
88 [](const sk_sp<CompositeTransform>& node, const SkPoint& a) {
89 node->setAnchorPoint(a);
90 });
91 auto position_attached = AttachProperty<VectorValue, SkPoint>(t["p"], ctx, xform,
92 [](const sk_sp<CompositeTransform>& node, const SkPoint& p) {
93 node->setPosition(p);
94 });
95 auto scale_attached = AttachProperty<VectorValue, SkVector>(t["s"], ctx, xform,
96 [](const sk_sp<CompositeTransform>& node, const SkVector& s) {
97 node->setScale(s);
98 });
99 auto rotation_attached = AttachProperty<ScalarValue, SkScalar>(t["r"], ctx, xform,
100 [](const sk_sp<CompositeTransform>& node, SkScalar r) {
101 node->setRotation(r);
102 });
103 auto skew_attached = AttachProperty<ScalarValue, SkScalar>(t["sk"], ctx, xform,
104 [](const sk_sp<CompositeTransform>& node, SkScalar sk) {
105 node->setSkew(sk);
106 });
107 auto skewaxis_attached = AttachProperty<ScalarValue, SkScalar>(t["sa"], ctx, xform,
108 [](const sk_sp<CompositeTransform>& node, SkScalar sa) {
109 node->setSkewAxis(sa);
110 });
111
112 if (!anchor_attached &&
113 !position_attached &&
114 !scale_attached &&
115 !rotation_attached &&
116 !skew_attached &&
117 !skewaxis_attached) {
118 LogFail(t, "Could not parse transform");
119 return wrapped_node;
120 }
121
122 return xform->node();
123}
124
125sk_sp<sksg::RenderNode> AttachShape(const Json::Value&, AttachContext* ctx);
126sk_sp<sksg::RenderNode> AttachComposition(const Json::Value&, AttachContext* ctx);
127
128sk_sp<sksg::RenderNode> AttachShapeGroup(const Json::Value& jgroup, AttachContext* ctx) {
129 SkASSERT(jgroup.isObject());
130
131 return AttachShape(jgroup["it"], ctx);
132}
133
134sk_sp<sksg::GeometryNode> AttachPathGeometry(const Json::Value& jpath, AttachContext* ctx) {
135 SkASSERT(jpath.isObject());
136
137 auto path_node = sksg::Path::Make();
138 auto path_attached = AttachProperty<ShapeValue, SkPath>(jpath["ks"], ctx, path_node,
139 [](const sk_sp<sksg::Path>& node, const SkPath& p) { node->setPath(p); });
140
141 if (path_attached)
142 LOG("** Attached path geometry - verbs: %d\n", path_node->getPath().countVerbs());
143
144 return path_attached ? path_node : nullptr;
145}
146
Florin Malita2e1d7e22018-01-02 10:40:00 -0500147sk_sp<sksg::GeometryNode> AttachRRectGeometry(const Json::Value& jrect, AttachContext* ctx) {
148 SkASSERT(jrect.isObject());
149
150 auto rect_node = sksg::RRect::Make();
151 auto composite = sk_make_sp<CompositeRRect>(rect_node);
152
153 auto p_attached = AttachProperty<VectorValue, SkPoint>(jrect["p"], ctx, composite,
154 [](const sk_sp<CompositeRRect>& node, const SkPoint& pos) { node->setPosition(pos); });
155 auto s_attached = AttachProperty<VectorValue, SkSize>(jrect["s"], ctx, composite,
156 [](const sk_sp<CompositeRRect>& node, const SkSize& sz) { node->setSize(sz); });
157 auto r_attached = AttachProperty<ScalarValue, SkScalar>(jrect["r"], ctx, composite,
158 [](const sk_sp<CompositeRRect>& node, SkScalar radius) { node->setRadius(radius); });
159
160 if (!p_attached && !s_attached && !r_attached) {
161 return nullptr;
162 }
163
164 return rect_node;
165}
166
Florin Malita094ccde2017-12-30 12:27:00 -0500167sk_sp<sksg::Color> AttachColorPaint(const Json::Value& obj, AttachContext* ctx) {
168 SkASSERT(obj.isObject());
169
170 auto color_node = sksg::Color::Make(SK_ColorBLACK);
171 color_node->setAntiAlias(true);
172
173 auto color_attached = AttachProperty<VectorValue, SkColor>(obj["c"], ctx, color_node,
174 [](const sk_sp<sksg::Color>& node, SkColor c) { node->setColor(c); });
175
176 return color_attached ? color_node : nullptr;
177}
178
179sk_sp<sksg::PaintNode> AttachFillPaint(const Json::Value& jfill, AttachContext* ctx) {
180 SkASSERT(jfill.isObject());
181
182 auto color = AttachColorPaint(jfill, ctx);
183 if (color) {
184 LOG("** Attached color fill: 0x%x\n", color->getColor());
185 }
186 return color;
187}
188
189sk_sp<sksg::PaintNode> AttachStrokePaint(const Json::Value& jstroke, AttachContext* ctx) {
190 SkASSERT(jstroke.isObject());
191
192 auto stroke_node = AttachColorPaint(jstroke, ctx);
193 if (!stroke_node)
194 return nullptr;
195
196 LOG("** Attached color stroke: 0x%x\n", stroke_node->getColor());
197
198 stroke_node->setStyle(SkPaint::kStroke_Style);
199
200 auto width_attached = AttachProperty<ScalarValue, SkScalar>(jstroke["w"], ctx, stroke_node,
201 [](const sk_sp<sksg::Color>& node, SkScalar width) { node->setStrokeWidth(width); });
202 if (!width_attached)
203 return nullptr;
204
205 stroke_node->setStrokeMiter(ParseScalar(jstroke["ml"], 4));
206
207 static constexpr SkPaint::Join gJoins[] = {
208 SkPaint::kMiter_Join,
209 SkPaint::kRound_Join,
210 SkPaint::kBevel_Join,
211 };
212 stroke_node->setStrokeJoin(gJoins[SkTPin<int>(ParseInt(jstroke["lj"], 1) - 1,
213 0, SK_ARRAY_COUNT(gJoins) - 1)]);
214
215 static constexpr SkPaint::Cap gCaps[] = {
216 SkPaint::kButt_Cap,
217 SkPaint::kRound_Cap,
218 SkPaint::kSquare_Cap,
219 };
220 stroke_node->setStrokeCap(gCaps[SkTPin<int>(ParseInt(jstroke["lc"], 1) - 1,
221 0, SK_ARRAY_COUNT(gCaps) - 1)]);
222
223 return stroke_node;
224}
225
226using GeometryAttacherT = sk_sp<sksg::GeometryNode> (*)(const Json::Value&, AttachContext*);
227static constexpr GeometryAttacherT gGeometryAttachers[] = {
228 AttachPathGeometry,
Florin Malita2e1d7e22018-01-02 10:40:00 -0500229 AttachRRectGeometry,
Florin Malita094ccde2017-12-30 12:27:00 -0500230};
231
232using PaintAttacherT = sk_sp<sksg::PaintNode> (*)(const Json::Value&, AttachContext*);
233static constexpr PaintAttacherT gPaintAttachers[] = {
234 AttachFillPaint,
235 AttachStrokePaint,
236};
237
238using GroupAttacherT = sk_sp<sksg::RenderNode> (*)(const Json::Value&, AttachContext*);
239static constexpr GroupAttacherT gGroupAttachers[] = {
240 AttachShapeGroup,
241};
242
Florin Malitadacc02b2017-12-31 09:12:31 -0500243using TransformAttacherT = sk_sp<sksg::RenderNode> (*)(const Json::Value&, AttachContext*,
244 sk_sp<sksg::RenderNode>);
245static constexpr TransformAttacherT gTransformAttachers[] = {
246 AttachTransform,
247};
248
Florin Malita094ccde2017-12-30 12:27:00 -0500249enum class ShapeType {
250 kGeometry,
251 kPaint,
252 kGroup,
Florin Malitadacc02b2017-12-31 09:12:31 -0500253 kTransform,
Florin Malita094ccde2017-12-30 12:27:00 -0500254};
255
256struct ShapeInfo {
257 const char* fTypeString;
258 ShapeType fShapeType;
259 uint32_t fAttacherIndex; // index into respective attacher tables
260};
261
262const ShapeInfo* FindShapeInfo(const Json::Value& shape) {
263 static constexpr ShapeInfo gShapeInfo[] = {
Florin Malitadacc02b2017-12-31 09:12:31 -0500264 { "fl", ShapeType::kPaint , 0 }, // fill -> AttachFillPaint
265 { "gr", ShapeType::kGroup , 0 }, // group -> AttachShapeGroup
Florin Malita2e1d7e22018-01-02 10:40:00 -0500266 { "rc", ShapeType::kGeometry , 1 }, // shape -> AttachRRectGeometry
Florin Malitadacc02b2017-12-31 09:12:31 -0500267 { "sh", ShapeType::kGeometry , 0 }, // shape -> AttachPathGeometry
268 { "st", ShapeType::kPaint , 1 }, // stroke -> AttachStrokePaint
269 { "tr", ShapeType::kTransform, 0 }, // transform -> AttachTransform
Florin Malita094ccde2017-12-30 12:27:00 -0500270 };
271
272 if (!shape.isObject())
273 return nullptr;
274
275 const auto& type = shape["ty"];
276 if (!type.isString())
277 return nullptr;
278
279 const auto* info = bsearch(type.asCString(),
280 gShapeInfo,
281 SK_ARRAY_COUNT(gShapeInfo),
282 sizeof(ShapeInfo),
283 [](const void* key, const void* info) {
284 return strcmp(static_cast<const char*>(key),
285 static_cast<const ShapeInfo*>(info)->fTypeString);
286 });
287
288 return static_cast<const ShapeInfo*>(info);
289}
290
291sk_sp<sksg::RenderNode> AttachShape(const Json::Value& shapeArray, AttachContext* ctx) {
292 if (!shapeArray.isArray())
293 return nullptr;
294
Florin Malita2a8275b2018-01-02 12:52:43 -0500295 // (https://helpx.adobe.com/after-effects/using/overview-shape-layers-paths-vector.html#groups_and_render_order_for_shapes_and_shape_attributes)
296 //
297 // Render order for shapes within a shape layer
298 //
299 // The rules for rendering a shape layer are similar to the rules for rendering a composition
300 // that contains nested compositions:
301 //
302 // * Within a group, the shape at the bottom of the Timeline panel stacking order is rendered
303 // first.
304 //
305 // * All path operations within a group are performed before paint operations. This means,
306 // for example, that the stroke follows the distortions in the path made by the Wiggle Paths
307 // path operation. Path operations within a group are performed from top to bottom.
308 //
309 // * Paint operations within a group are performed from the bottom to the top in the Timeline
310 // panel stacking order. This means, for example, that a stroke is rendered on top of
311 // (in front of) a stroke that appears after it in the Timeline panel.
312 //
Florin Malitadacc02b2017-12-31 09:12:31 -0500313 sk_sp<sksg::Group> shape_group = sksg::Group::Make();
314 sk_sp<sksg::RenderNode> xformed_group = shape_group;
Florin Malita094ccde2017-12-30 12:27:00 -0500315
316 SkSTArray<16, sk_sp<sksg::GeometryNode>, true> geos;
Florin Malita2a8275b2018-01-02 12:52:43 -0500317 SkSTArray<16, sk_sp<sksg::RenderNode> , true> draws;
Florin Malita094ccde2017-12-30 12:27:00 -0500318
319 for (const auto& s : shapeArray) {
320 const auto* info = FindShapeInfo(s);
321 if (!info) {
322 LogFail(s.isObject() ? s["ty"] : s, "Unknown shape");
323 continue;
324 }
325
326 switch (info->fShapeType) {
327 case ShapeType::kGeometry: {
328 SkASSERT(info->fAttacherIndex < SK_ARRAY_COUNT(gGeometryAttachers));
329 if (auto geo = gGeometryAttachers[info->fAttacherIndex](s, ctx)) {
330 geos.push_back(std::move(geo));
331 }
332 } break;
333 case ShapeType::kPaint: {
334 SkASSERT(info->fAttacherIndex < SK_ARRAY_COUNT(gPaintAttachers));
335 if (auto paint = gPaintAttachers[info->fAttacherIndex](s, ctx)) {
Florin Malita2a8275b2018-01-02 12:52:43 -0500336 for (const auto& geo : geos) {
337 draws.push_back(sksg::Draw::Make(geo, paint));
338 }
Florin Malita094ccde2017-12-30 12:27:00 -0500339 }
340 } break;
341 case ShapeType::kGroup: {
342 SkASSERT(info->fAttacherIndex < SK_ARRAY_COUNT(gGroupAttachers));
343 if (auto group = gGroupAttachers[info->fAttacherIndex](s, ctx)) {
Florin Malita2a8275b2018-01-02 12:52:43 -0500344 draws.push_back(std::move(group));
Florin Malita094ccde2017-12-30 12:27:00 -0500345 }
346 } break;
Florin Malitadacc02b2017-12-31 09:12:31 -0500347 case ShapeType::kTransform: {
Florin Malita2a8275b2018-01-02 12:52:43 -0500348 // TODO: BM appears to transform the geometry, not the draw op itself.
Florin Malitadacc02b2017-12-31 09:12:31 -0500349 SkASSERT(info->fAttacherIndex < SK_ARRAY_COUNT(gTransformAttachers));
350 xformed_group = gTransformAttachers[info->fAttacherIndex](s, ctx, xformed_group);
351 } break;
Florin Malita094ccde2017-12-30 12:27:00 -0500352 }
353 }
354
Florin Malita2a8275b2018-01-02 12:52:43 -0500355 if (draws.empty()) {
356 return nullptr;
Florin Malita094ccde2017-12-30 12:27:00 -0500357 }
358
Florin Malita2a8275b2018-01-02 12:52:43 -0500359 for (int i = draws.count() - 1; i >= 0; --i) {
360 shape_group->addChild(std::move(draws[i]));
361 }
362
363 LOG("** Attached shape: %d draws.\n", draws.count());
Florin Malitadacc02b2017-12-31 09:12:31 -0500364 return xformed_group;
Florin Malita094ccde2017-12-30 12:27:00 -0500365}
366
367sk_sp<sksg::RenderNode> AttachCompLayer(const Json::Value& layer, AttachContext* ctx) {
368 SkASSERT(layer.isObject());
369
370 auto refId = ParseString(layer["refId"], "");
371 if (refId.isEmpty()) {
372 LOG("!! Comp layer missing refId\n");
373 return nullptr;
374 }
375
376 const auto* comp = ctx->fAssets.find(refId);
377 if (!comp) {
378 LOG("!! Pre-comp not found: '%s'\n", refId.c_str());
379 return nullptr;
380 }
381
382 // TODO: cycle detection
383 return AttachComposition(**comp, ctx);
384}
385
386sk_sp<sksg::RenderNode> AttachSolidLayer(const Json::Value& layer, AttachContext*) {
387 SkASSERT(layer.isObject());
388
389 LOG("?? Solid layer stub\n");
390 return nullptr;
391}
392
393sk_sp<sksg::RenderNode> AttachImageLayer(const Json::Value& layer, AttachContext*) {
394 SkASSERT(layer.isObject());
395
396 LOG("?? Image layer stub\n");
397 return nullptr;
398}
399
400sk_sp<sksg::RenderNode> AttachNullLayer(const Json::Value& layer, AttachContext*) {
401 SkASSERT(layer.isObject());
402
403 LOG("?? Null layer stub\n");
404 return nullptr;
405}
406
407sk_sp<sksg::RenderNode> AttachShapeLayer(const Json::Value& layer, AttachContext* ctx) {
408 SkASSERT(layer.isObject());
409
410 LOG("** Attaching shape layer ind: %d\n", ParseInt(layer["ind"], 0));
411
412 return AttachShape(layer["shapes"], ctx);
413}
414
415sk_sp<sksg::RenderNode> AttachTextLayer(const Json::Value& layer, AttachContext*) {
416 SkASSERT(layer.isObject());
417
418 LOG("?? Text layer stub\n");
419 return nullptr;
420}
421
422sk_sp<sksg::RenderNode> AttachLayer(const Json::Value& layer, AttachContext* ctx) {
423 if (!layer.isObject())
424 return nullptr;
425
426 using LayerAttacher = sk_sp<sksg::RenderNode> (*)(const Json::Value&, AttachContext*);
427 static constexpr LayerAttacher gLayerAttachers[] = {
428 AttachCompLayer, // 'ty': 0
429 AttachSolidLayer, // 'ty': 1
430 AttachImageLayer, // 'ty': 2
431 AttachNullLayer, // 'ty': 3
432 AttachShapeLayer, // 'ty': 4
433 AttachTextLayer, // 'ty': 5
434 };
435
436 int type = ParseInt(layer["ty"], -1);
437 if (type < 0 || type >= SkTo<int>(SK_ARRAY_COUNT(gLayerAttachers))) {
438 return nullptr;
439 }
440
441 return AttachTransform(layer["ks"], ctx, gLayerAttachers[type](layer, ctx));
442}
443
444sk_sp<sksg::RenderNode> AttachComposition(const Json::Value& comp, AttachContext* ctx) {
445 if (!comp.isObject())
446 return nullptr;
447
Florin Malita2a8275b2018-01-02 12:52:43 -0500448 SkSTArray<16, sk_sp<sksg::RenderNode>, true> layers;
Florin Malita094ccde2017-12-30 12:27:00 -0500449
450 for (const auto& l : comp["layers"]) {
451 if (auto layer_fragment = AttachLayer(l, ctx)) {
Florin Malita2a8275b2018-01-02 12:52:43 -0500452 layers.push_back(std::move(layer_fragment));
Florin Malita094ccde2017-12-30 12:27:00 -0500453 }
454 }
455
Florin Malita2a8275b2018-01-02 12:52:43 -0500456 if (layers.empty()) {
457 return nullptr;
458 }
459
460 // Layers are painted in bottom->top order.
461 auto comp_group = sksg::Group::Make();
462 for (int i = layers.count() - 1; i >= 0; --i) {
463 comp_group->addChild(std::move(layers[i]));
464 }
465
466 LOG("** Attached composition '%s': %d layers.\n",
467 ParseString(comp["id"], "").c_str(), layers.count());
468
Florin Malita094ccde2017-12-30 12:27:00 -0500469 return comp_group;
470}
471
472} // namespace
473
474std::unique_ptr<Animation> Animation::Make(SkStream* stream) {
475 if (!stream->hasLength()) {
476 // TODO: handle explicit buffering?
477 LOG("!! cannot parse streaming content\n");
478 return nullptr;
479 }
480
481 Json::Value json;
482 {
483 auto data = SkData::MakeFromStream(stream, stream->getLength());
484 if (!data) {
485 LOG("!! could not read stream\n");
486 return nullptr;
487 }
488
489 Json::Reader reader;
490
491 auto dataStart = static_cast<const char*>(data->data());
492 if (!reader.parse(dataStart, dataStart + data->size(), json, false) || !json.isObject()) {
493 LOG("!! failed to parse json: %s\n", reader.getFormattedErrorMessages().c_str());
494 return nullptr;
495 }
496 }
497
498 const auto version = ParseString(json["v"], "");
499 const auto size = SkSize::Make(ParseScalar(json["w"], -1), ParseScalar(json["h"], -1));
500 const auto fps = ParseScalar(json["fr"], -1);
501
502 if (size.isEmpty() || version.isEmpty() || fps < 0) {
503 LOG("!! invalid animation params (version: %s, size: [%f %f], frame rate: %f)",
504 version.c_str(), size.width(), size.height(), fps);
505 return nullptr;
506 }
507
508 return std::unique_ptr<Animation>(new Animation(std::move(version), size, fps, json));
509}
510
511Animation::Animation(SkString version, const SkSize& size, SkScalar fps, const Json::Value& json)
512 : fVersion(std::move(version))
513 , fSize(size)
514 , fFrameRate(fps)
515 , fInPoint(ParseScalar(json["ip"], 0))
516 , fOutPoint(SkTMax(ParseScalar(json["op"], SK_ScalarMax), fInPoint)) {
517
518 AssetMap assets;
519 for (const auto& asset : json["assets"]) {
520 if (!asset.isObject()) {
521 continue;
522 }
523
524 assets.set(ParseString(asset["id"], ""), &asset);
525 }
526
527 AttachContext ctx = { assets, fAnimators };
528 fDom = AttachComposition(json, &ctx);
529
530 LOG("** Attached %d animators\n", fAnimators.count());
531}
532
533Animation::~Animation() = default;
534
535void Animation::render(SkCanvas* canvas) const {
536 if (!fDom)
537 return;
538
539 sksg::InvalidationController ic;
540 fDom->revalidate(&ic, SkMatrix::I());
541
542 // TODO: proper inval
543 fDom->render(canvas);
544
545 if (!fShowInval)
546 return;
547
548 SkPaint fill, stroke;
549 fill.setAntiAlias(true);
550 fill.setColor(0x40ff0000);
551 stroke.setAntiAlias(true);
552 stroke.setColor(0xffff0000);
553 stroke.setStyle(SkPaint::kStroke_Style);
554
555 for (const auto& r : ic) {
556 canvas->drawRect(r, fill);
557 canvas->drawRect(r, stroke);
558 }
559}
560
561void Animation::animationTick(SkMSec ms) {
562 // 't' in the BM model really means 'frame #'
563 auto t = static_cast<float>(ms) * fFrameRate / 1000;
564
565 t = fInPoint + std::fmod(t, fOutPoint - fInPoint);
566
567 // TODO: this can be optimized quite a bit with some sorting/state tracking.
568 for (const auto& a : fAnimators) {
569 a->tick(t);
570 }
571}
572
573} // namespace skotty