diff --git a/libscratchcpp b/libscratchcpp index 1b01316..d14fe3b 160000 --- a/libscratchcpp +++ b/libscratchcpp @@ -1 +1 @@ -Subproject commit 1b01316866ca0f681445c7dfafdc3ae531f65536 +Subproject commit d14fe3b07835637504dc8855b606c6094ddd3879 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index aef42a9..288117b 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -12,6 +12,7 @@ qt_add_qml_module(scratchcpp-render internal/ValueMonitor.qml internal/MonitorSlider.qml internal/ListMonitor.qml + internal/TextBubble.qml shaders/sprite.vert shaders/sprite.frag SOURCES @@ -62,6 +63,10 @@ qt_add_qml_module(scratchcpp-render shadermanager.h graphicseffect.cpp graphicseffect.h + textbubbleshape.cpp + textbubbleshape.h + textbubblepainter.cpp + textbubblepainter.h blocks/penextension.cpp blocks/penextension.h blocks/penblocks.cpp diff --git a/src/ProjectPlayer.qml b/src/ProjectPlayer.qml index 0627a42..ea79080 100644 --- a/src/ProjectPlayer.qml +++ b/src/ProjectPlayer.qml @@ -109,6 +109,20 @@ ProjectScene { onStageModelChanged: stageModel.renderedTarget = this } + Loader { + readonly property alias model: stageTarget.stageModel + active: model ? model.bubbleText !== "" : false + + sourceComponent: TextBubble { + type: model ? model.bubbleType : TextBubbleShape.Say + text: model ? model.bubbleText : "" + target: stageTarget + stageScale: root.stageScale + stageWidth: root.stageWidth + stageHeight: root.stageHeight + } + } + PenLayer { id: projectPenLayer engine: loader.engine @@ -118,16 +132,34 @@ ProjectScene { Component { id: renderedSprite - RenderedTarget { - id: target - mouseArea: sceneMouseArea - stageScale: root.stageScale - transform: Scale { xScale: mirrorHorizontally ? -1 : 1 } - Component.onCompleted: { - engine = loader.engine; - spriteModel = modelData; - spriteModel.renderedTarget = this; - spriteModel.penLayer = projectPenLayer; + Item { + anchors.fill: parent + + RenderedTarget { + id: targetItem + mouseArea: sceneMouseArea + stageScale: root.stageScale + transform: Scale { xScale: targetItem.mirrorHorizontally ? -1 : 1 } + Component.onCompleted: { + engine = loader.engine; + spriteModel = modelData; + spriteModel.renderedTarget = this; + spriteModel.penLayer = projectPenLayer; + } + } + + Loader { + readonly property alias model: targetItem.spriteModel + active: model ? model.bubbleText !== "" : false + + sourceComponent: TextBubble { + type: model ? model.bubbleType : TextBubbleShape.Say + text: model ? model.bubbleText : "" + target: targetItem + stageScale: root.stageScale + stageWidth: root.stageWidth + stageHeight: root.stageHeight + } } } } diff --git a/src/internal/TextBubble.qml b/src/internal/TextBubble.qml new file mode 100644 index 0000000..8a77084 --- /dev/null +++ b/src/internal/TextBubble.qml @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +import QtQuick +import ScratchCPP.Render + +TextBubbleShape { + id: root + property string text: "" + property RenderedTarget target: null + property double stageWidth: 0 + property double stageHeight: 0 + + QtObject { + // https://github.com/scratchfoundation/scratch-render/blob/ac935423afe3ba79235750eecb1e443474c6eb09/src/TextBubbleSkin.js#L7-L26 + id: priv + readonly property int maxLineWidth: 170 + readonly property int minWidth: 50 + readonly property int padding: 10 + readonly property int tailHeight: 12 + + readonly property string fontFamily: "Helvetica" + readonly property int fontPixelSize: 14 + readonly property int lineHeight: 16 + + readonly property color textFill: '#575E75' + + function translateX(x) { + // Translates Scratch X-coordinate to the scene coordinate system + return root.stageScale * (root.stageWidth / 2 + x) + } + + function translateY(y) { + // Translates Scratch Y-coordinate to the scene coordinate system + return root.stageScale * (root.stageHeight / 2 - y) + } + } + + nativeWidth: Math.max(bubbleText.contentWidth, priv.minWidth) + 2 * priv.padding + nativeHeight: bubbleText.height + 2 * priv.padding + priv.tailHeight + + function positionBubble() { + // https://github.com/scratchfoundation/scratch-vm/blob/7313ce5199f8a3da7850085d0f7f6a3ca2c89bf6/src/blocks/scratch3_looks.js#L158 + if(!target.visible) + return; + + const targetBounds = target.getBoundsForBubble(); + const stageBounds = Qt.rect(-root.stageWidth / 2, root.stageHeight / 2, root.stageWidth, root.stageHeight); + + if (onSpriteRight && nativeWidth + targetBounds.right > stageBounds.right && + (targetBounds.left - nativeWidth > stageBounds.left)) { // Only flip if it would fit + onSpriteRight = false; + } else if (!onSpriteRight && targetBounds.left - nativeWidth < stageBounds.left && + (nativeWidth + targetBounds.right < stageBounds.right)) { // Only flip if it would fit + onSpriteRight = true; + } + + const pos = [ + onSpriteRight ? ( + Math.max( + stageBounds.left, // Bubble should not extend past left edge of stage + Math.min(stageBounds.right - nativeWidth, targetBounds.right) + ) + ) : ( + Math.min( + stageBounds.right - nativeWidth, // Bubble should not extend past right edge of stage + Math.max(stageBounds.left, targetBounds.left - nativeWidth) + ) + ), + // Bubble should not extend past the top of the stage + Math.min(stageBounds.top, targetBounds.bottom + nativeHeight) + ]; + + x = priv.translateX(pos[0]); + y = priv.translateY(pos[1]); + } + + Connections { + target: root.target + + function onXChanged() { positionBubble() } + function onYChanged() { positionBubble() } + function onRotationChanged() { positionBubble() } + function onWidthChanged() { positionBubble() } + function onHeightChanged() { positionBubble() } + function onScaleChanged() { positionBubble() } + } + + Text { + id: bubbleText + anchors.left: parent.left + anchors.top: parent.top + anchors.margins: priv.padding * root.stageScale + width: priv.maxLineWidth + scale: root.stageScale + transformOrigin: Item.TopLeft + text: root.text + wrapMode: Text.Wrap + color: priv.textFill + lineHeight: priv.lineHeight + lineHeightMode: Text.FixedHeight + font.family: priv.fontFamily + font.pixelSize: priv.fontPixelSize + } + + Component.onCompleted: positionBubble() +} diff --git a/src/irenderedtarget.h b/src/irenderedtarget.h index b1742ae..6d70c57 100644 --- a/src/irenderedtarget.h +++ b/src/irenderedtarget.h @@ -70,6 +70,7 @@ class IRenderedTarget : public QNanoQuickItem virtual void setHeight(qreal width) = 0; virtual libscratchcpp::Rect getBounds() const = 0; + virtual QRectF getBoundsForBubble() const = 0; virtual QPointF mapFromScene(const QPointF &point) const = 0; diff --git a/src/renderedtarget.cpp b/src/renderedtarget.cpp index 5a175a8..030400d 100644 --- a/src/renderedtarget.cpp +++ b/src/renderedtarget.cpp @@ -366,6 +366,19 @@ Rect RenderedTarget::getBounds() const return Rect(left + m_x, top + m_y, right + m_x, bottom + m_y); } +QRectF RenderedTarget::getBoundsForBubble() const +{ + // https://github.com/scratchfoundation/scratch-render/blob/86dcb0151a04bc8c1ff39559e8531e7921102b56/src/Drawable.js#L536-L551 + Rect rect = getBounds(); + const int slice = 8; // px, how tall the top slice to measure should be + + if (rect.height() > slice) + rect.setBottom(rect.top() - slice); + + Q_ASSERT(rect.height() <= 8); + return QRectF(QPointF(rect.left(), rect.top()), QPointF(rect.right(), rect.bottom())); +} + QPointF RenderedTarget::mapFromScene(const QPointF &point) const { return QNanoQuickItem::mapFromScene(point); @@ -463,9 +476,6 @@ void RenderedTarget::clearGraphicEffects() void RenderedTarget::updateHullPoints(QOpenGLFramebufferObject *fbo) { - if (m_stageModel) - return; // hull points are useless for the stage - Q_ASSERT(fbo); int width = fbo->width(); int height = fbo->height(); diff --git a/src/renderedtarget.h b/src/renderedtarget.h index edf57e5..7d80182 100644 --- a/src/renderedtarget.h +++ b/src/renderedtarget.h @@ -75,6 +75,7 @@ class RenderedTarget : public IRenderedTarget void setHeight(qreal height) override; libscratchcpp::Rect getBounds() const override; + Q_INVOKABLE QRectF getBoundsForBubble() const override; QPointF mapFromScene(const QPointF &point) const override; diff --git a/src/spritemodel.cpp b/src/spritemodel.cpp index cca1c02..706bed7 100644 --- a/src/spritemodel.cpp +++ b/src/spritemodel.cpp @@ -113,6 +113,33 @@ void SpriteModel::onGraphicsEffectsCleared() m_renderedTarget->clearGraphicEffects(); } +void SpriteModel::onBubbleTypeChanged(libscratchcpp::Target::BubbleType type) +{ + if (type == libscratchcpp::Target::BubbleType::Say) { + if (m_bubbleType == TextBubbleShape::Type::Say) + return; + + m_bubbleType = TextBubbleShape::Type::Say; + } else { + if (m_bubbleType == TextBubbleShape::Type::Think) + return; + + m_bubbleType = TextBubbleShape::Type::Think; + } + + emit bubbleTypeChanged(); +} + +void SpriteModel::onBubbleTextChanged(const std::string &text) +{ + QString newText = QString::fromStdString(text); + + if (m_bubbleText != newText) { + m_bubbleText = newText; + emit bubbleTextChanged(); + } +} + libscratchcpp::Rect SpriteModel::boundingRect() const { return m_renderedTarget->getBounds(); @@ -187,4 +214,14 @@ SpriteModel *SpriteModel::cloneRoot() const return m_cloneRoot; } +const TextBubbleShape::Type &SpriteModel::bubbleType() const +{ + return m_bubbleType; +} + +const QString &SpriteModel::bubbleText() const +{ + return m_bubbleText; +} + } // namespace scratchcpprender diff --git a/src/spritemodel.h b/src/spritemodel.h index c289d85..cb4903e 100644 --- a/src/spritemodel.h +++ b/src/spritemodel.h @@ -7,6 +7,7 @@ #include #include "penstate.h" +#include "textbubbleshape.h" Q_MOC_INCLUDE("renderedtarget.h"); Q_MOC_INCLUDE("ipenlayer.h"); @@ -25,6 +26,8 @@ class SpriteModel QML_ELEMENT Q_PROPERTY(IRenderedTarget *renderedTarget READ renderedTarget WRITE setRenderedTarget NOTIFY renderedTargetChanged) Q_PROPERTY(IPenLayer *penLayer READ penLayer WRITE setPenLayer NOTIFY penLayerChanged) + Q_PROPERTY(TextBubbleShape::Type bubbleType READ bubbleType NOTIFY bubbleTypeChanged) + Q_PROPERTY(QString bubbleText READ bubbleText NOTIFY bubbleTextChanged) public: SpriteModel(QObject *parent = nullptr); @@ -48,6 +51,9 @@ class SpriteModel void onGraphicsEffectChanged(libscratchcpp::IGraphicsEffect *effect, double value) override; void onGraphicsEffectsCleared() override; + void onBubbleTypeChanged(libscratchcpp::Target::BubbleType type) override; + void onBubbleTextChanged(const std::string &text) override; + libscratchcpp::Rect boundingRect() const override; libscratchcpp::Sprite *sprite() const; @@ -66,9 +72,15 @@ class SpriteModel SpriteModel *cloneRoot() const; + const TextBubbleShape::Type &bubbleType() const; + + const QString &bubbleText() const; + signals: void renderedTargetChanged(); void penLayerChanged(); + void bubbleTypeChanged(); + void bubbleTextChanged(); void cloned(SpriteModel *cloneModel); void cloneDeleted(SpriteModel *clone); @@ -78,6 +90,8 @@ class SpriteModel IPenLayer *m_penLayer = nullptr; PenState m_penState; SpriteModel *m_cloneRoot = nullptr; + TextBubbleShape::Type m_bubbleType = TextBubbleShape::Type::Say; + QString m_bubbleText; }; } // namespace scratchcpprender diff --git a/src/stagemodel.cpp b/src/stagemodel.cpp index 36a15b0..5c600ec 100644 --- a/src/stagemodel.cpp +++ b/src/stagemodel.cpp @@ -50,6 +50,33 @@ void StageModel::onGraphicsEffectsCleared() m_renderedTarget->clearGraphicEffects(); } +void StageModel::onBubbleTypeChanged(libscratchcpp::Target::BubbleType type) +{ + if (type == libscratchcpp::Target::BubbleType::Say) { + if (m_bubbleType == TextBubbleShape::Type::Say) + return; + + m_bubbleType = TextBubbleShape::Type::Say; + } else { + if (m_bubbleType == TextBubbleShape::Type::Think) + return; + + m_bubbleType = TextBubbleShape::Type::Think; + } + + emit bubbleTypeChanged(); +} + +void StageModel::onBubbleTextChanged(const std::string &text) +{ + QString newText = QString::fromStdString(text); + + if (m_bubbleText != newText) { + m_bubbleText = newText; + emit bubbleTextChanged(); + } +} + void StageModel::loadCostume() { if (m_renderedTarget && m_stage) { @@ -78,3 +105,13 @@ void StageModel::setRenderedTarget(IRenderedTarget *newRenderedTarget) emit renderedTargetChanged(); } + +const TextBubbleShape::Type &StageModel::bubbleType() const +{ + return m_bubbleType; +} + +const QString &StageModel::bubbleText() const +{ + return m_bubbleText; +} diff --git a/src/stagemodel.h b/src/stagemodel.h index 51a7066..11e3929 100644 --- a/src/stagemodel.h +++ b/src/stagemodel.h @@ -5,6 +5,8 @@ #include #include +#include "textbubbleshape.h" + Q_MOC_INCLUDE("renderedtarget.h"); namespace scratchcpprender @@ -18,6 +20,8 @@ class StageModel { Q_OBJECT Q_PROPERTY(IRenderedTarget *renderedTarget READ renderedTarget WRITE setRenderedTarget NOTIFY renderedTargetChanged) + Q_PROPERTY(TextBubbleShape::Type bubbleType READ bubbleType NOTIFY bubbleTypeChanged) + Q_PROPERTY(QString bubbleText READ bubbleText NOTIFY bubbleTextChanged) public: explicit StageModel(QObject *parent = nullptr); @@ -33,6 +37,9 @@ class StageModel void onGraphicsEffectChanged(libscratchcpp::IGraphicsEffect *effect, double value) override; void onGraphicsEffectsCleared() override; + void onBubbleTypeChanged(libscratchcpp::Target::BubbleType type) override; + void onBubbleTextChanged(const std::string &text) override; + Q_INVOKABLE void loadCostume(); libscratchcpp::Stage *stage() const; @@ -40,12 +47,20 @@ class StageModel IRenderedTarget *renderedTarget() const; void setRenderedTarget(IRenderedTarget *newRenderedTarget); + const TextBubbleShape::Type &bubbleType() const; + + const QString &bubbleText() const; + signals: void renderedTargetChanged(); + void bubbleTypeChanged(); + void bubbleTextChanged(); private: libscratchcpp::Stage *m_stage = nullptr; IRenderedTarget *m_renderedTarget = nullptr; + TextBubbleShape::Type m_bubbleType = TextBubbleShape::Type::Say; + QString m_bubbleText; }; } // namespace scratchcpprender diff --git a/src/textbubblepainter.cpp b/src/textbubblepainter.cpp new file mode 100644 index 0000000..e4e50af --- /dev/null +++ b/src/textbubblepainter.cpp @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "textbubblepainter.h" +#include "textbubbleshape.h" + +using namespace scratchcpprender; + +// https://github.com/scratchfoundation/scratch-render/blob/ac935423afe3ba79235750eecb1e443474c6eb09/src/TextBubbleSkin.js#L7-L26 +static const int STROKE_WIDTH = 4; +static const int CORNER_RADIUS = 16; +static const int TAIL_HEIGHT = 12; +static const QNanoColor BUBBLE_FILL_COLOR = QNanoColor(255, 255, 255); +static const QNanoColor BUBBLE_STROKE_COLOR = QNanoColor(0, 0, 0, 38); + +static const double pi = std::acos(-1); // TODO: Use std::numbers::pi in C++20 + +void TextBubblePainter::paint(QNanoPainter *painter) +{ + // https://github.com/scratchfoundation/scratch-render/blob/ac935423afe3ba79235750eecb1e443474c6eb09/src/TextBubbleSkin.js#L149-L242 + if (!m_item) + return; + + const double scale = m_item->stageScale(); + const double width = m_item->nativeWidth() - STROKE_WIDTH; + const double height = m_item->nativeHeight() - STROKE_WIDTH - TAIL_HEIGHT; + + painter->resetTransform(); + painter->scale(scale, scale); + painter->translate(STROKE_WIDTH * 0.5, STROKE_WIDTH * 0.5); + + // If the text bubble points leftward, flip the canvas + painter->save(); + + if (m_item->onSpriteRight()) { + painter->scale(-1, 1); + painter->translate(-width, 0); + } + + // Draw the bubble's rounded borders + painter->beginPath(); + painter->moveTo(CORNER_RADIUS, height); + painter->arcTo(0, height, 0, height - CORNER_RADIUS, CORNER_RADIUS); + painter->arcTo(0, 0, width, 0, CORNER_RADIUS); + painter->arcTo(width, 0, width, height, CORNER_RADIUS); + painter->arcTo(width, height, width - CORNER_RADIUS, height, CORNER_RADIUS); + + // Translate the canvas so we don't have to do a bunch of width/height arithmetic + painter->save(); + painter->translate(width - CORNER_RADIUS, height); + + // Draw the bubble's "tail" + if (m_item->type() == TextBubbleShape::Type::Say) { + // For a speech bubble, draw one swoopy thing + painter->bezierTo(0, 4, 4, 8, 4, 10); + painter->arcTo(4, 12, 2, 12, 2); + painter->bezierTo(-1, 12, -11, 8, -16, 0); + + painter->closePath(); + } else { + // For a thinking bubble, draw a partial circle attached to the bubble... + painter->arc(-16, 0, 4, 0, pi); + + painter->closePath(); + + // and two circles detached from it + painter->moveTo(-7, 7.25); + painter->arc(-9.25, 7.25, 2.25, 0, pi * 2); + + painter->moveTo(0, 9.5); + painter->arc(-1.5, 9.5, 1.5, 0, pi * 2); + } + + // Un-translate the canvas and fill + stroke the text bubble + painter->restore(); + + painter->setFillStyle(BUBBLE_FILL_COLOR); + painter->setStrokeStyle(BUBBLE_STROKE_COLOR); + painter->setLineWidth(STROKE_WIDTH); + + painter->stroke(); + painter->fill(); +} + +void TextBubblePainter::synchronize(QNanoQuickItem *item) +{ + Q_ASSERT(item); + m_item = static_cast(item); +} diff --git a/src/textbubblepainter.h b/src/textbubblepainter.h new file mode 100644 index 0000000..265c8e8 --- /dev/null +++ b/src/textbubblepainter.h @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +#pragma once + +#include + +namespace scratchcpprender +{ + +class TextBubbleShape; + +class TextBubblePainter : public QNanoQuickItemPainter +{ + public: + void paint(QNanoPainter *painter) override; + void synchronize(QNanoQuickItem *item) override; + + private: + TextBubbleShape *m_item = nullptr; +}; + +} // namespace scratchcpprender diff --git a/src/textbubbleshape.cpp b/src/textbubbleshape.cpp new file mode 100644 index 0000000..dd73fcb --- /dev/null +++ b/src/textbubbleshape.cpp @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "textbubbleshape.h" +#include "textbubblepainter.h" + +using namespace scratchcpprender; + +TextBubbleShape::TextBubbleShape(QQuickItem *parent) : + QNanoQuickItem(parent) +{ +} + +QNanoQuickItemPainter *TextBubbleShape::createItemPainter() const +{ + return new TextBubblePainter; +} + +TextBubbleShape::Type TextBubbleShape::type() const +{ + return m_type; +} + +void TextBubbleShape::setType(Type newType) +{ + if (m_type == newType) + return; + + m_type = newType; + update(); + emit typeChanged(); +} + +bool TextBubbleShape::onSpriteRight() const +{ + return m_onSpriteRight; +} + +void TextBubbleShape::setOnSpriteRight(bool newOnSpriteRight) +{ + if (m_onSpriteRight == newOnSpriteRight) + return; + + m_onSpriteRight = newOnSpriteRight; + update(); + emit onSpriteRightChanged(); +} + +double TextBubbleShape::stageScale() const +{ + return m_stageScale; +} + +void TextBubbleShape::setStageScale(double newStageScale) +{ + if (qFuzzyCompare(m_stageScale, newStageScale)) + return; + + m_stageScale = newStageScale; + setWidth(m_nativeWidth * m_stageScale); + setHeight(m_nativeHeight * m_stageScale); + emit stageScaleChanged(); +} + +double TextBubbleShape::nativeWidth() const +{ + return m_nativeWidth; +} + +void TextBubbleShape::setNativeWidth(double newNativeWidth) +{ + if (qFuzzyCompare(m_nativeWidth, newNativeWidth)) + return; + + m_nativeWidth = newNativeWidth; + setWidth(m_nativeWidth * m_stageScale); + emit nativeWidthChanged(); +} + +double TextBubbleShape::nativeHeight() const +{ + return m_nativeHeight; +} + +void TextBubbleShape::setNativeHeight(double newNativeHeight) +{ + if (qFuzzyCompare(m_nativeHeight, newNativeHeight)) + return; + + m_nativeHeight = newNativeHeight; + setHeight(m_nativeHeight * m_stageScale); + emit nativeHeightChanged(); +} diff --git a/src/textbubbleshape.h b/src/textbubbleshape.h new file mode 100644 index 0000000..4f85439 --- /dev/null +++ b/src/textbubbleshape.h @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +#pragma once + +#include + +namespace scratchcpprender +{ + +class TextBubbleShape : public QNanoQuickItem +{ + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(Type type READ type WRITE setType NOTIFY typeChanged) + Q_PROPERTY(bool onSpriteRight READ onSpriteRight WRITE setOnSpriteRight NOTIFY onSpriteRightChanged) + Q_PROPERTY(double stageScale READ stageScale WRITE setStageScale NOTIFY stageScaleChanged) + Q_PROPERTY(double nativeWidth READ nativeWidth WRITE setNativeWidth NOTIFY nativeWidthChanged) + Q_PROPERTY(double nativeHeight READ nativeHeight WRITE setNativeHeight NOTIFY nativeHeightChanged) + + public: + enum class Type + { + Say, + Think + }; + + Q_ENUM(Type) + + TextBubbleShape(QQuickItem *parent = nullptr); + + Type type() const; + void setType(Type newType); + + bool onSpriteRight() const; + void setOnSpriteRight(bool newOnSpriteRight); + + double stageScale() const; + void setStageScale(double newStageScale); + + double nativeWidth() const; + void setNativeWidth(double newNativeWidth); + + double nativeHeight() const; + void setNativeHeight(double newNativeHeight); + + signals: + void typeChanged(); + void onSpriteRightChanged(); + void stageScaleChanged(); + void nativeWidthChanged(); + void nativeHeightChanged(); + + protected: + QNanoQuickItemPainter *createItemPainter() const override; + + private: + Type m_type = Type::Say; + bool m_onSpriteRight = true; + double m_stageScale = 1; + double m_nativeWidth = 0; + double m_nativeHeight = 0; +}; + +} // namespace scratchcpprender diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 857d27e..463fd1f 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -38,3 +38,5 @@ add_subdirectory(penlayerpainter) add_subdirectory(blocks) add_subdirectory(graphicseffect) add_subdirectory(shadermanager) +add_subdirectory(textbubbleshape) +add_subdirectory(textbubblepainter) diff --git a/test/mocks/renderedtargetmock.h b/test/mocks/renderedtargetmock.h index ec0fd99..aa31376 100644 --- a/test/mocks/renderedtargetmock.h +++ b/test/mocks/renderedtargetmock.h @@ -56,6 +56,7 @@ class RenderedTargetMock : public IRenderedTarget MOCK_METHOD(QPointF, mapFromScene, (const QPointF &), (const, override)); MOCK_METHOD(libscratchcpp::Rect, getBounds, (), (const, override)); + MOCK_METHOD(QRectF, getBoundsForBubble, (), (const, override)); MOCK_METHOD(bool, mirrorHorizontally, (), (const, override)); diff --git a/test/renderedtarget/renderedtarget_test.cpp b/test/renderedtarget/renderedtarget_test.cpp index 57f0b47..ba79e3b 100644 --- a/test/renderedtarget/renderedtarget_test.cpp +++ b/test/renderedtarget/renderedtarget_test.cpp @@ -326,10 +326,6 @@ TEST_F(RenderedTargetTest, HullPoints) target.updateHullPoints(&fbo); ASSERT_EQ(target.hullPoints(), std::vector({ { 1, 1 }, { 2, 1 }, { 3, 1 }, { 1, 2 }, { 3, 2 }, { 1, 3 }, { 2, 3 }, { 3, 3 } })); - // Release - fbo.release(); - context.doneCurrent(); - // Test contains() ASSERT_FALSE(target.contains({ 0, 0 })); ASSERT_FALSE(target.contains({ 1, 0 })); @@ -351,6 +347,51 @@ TEST_F(RenderedTargetTest, HullPoints) ASSERT_TRUE(target.contains({ 2, 3 })); ASSERT_TRUE(target.contains({ 3, 3 })); ASSERT_FALSE(target.contains({ 3.3, 3.5 })); + + // Stage: hull points + Stage stage; + StageModel stageModel; + stageModel.init(&stage); + target.setSpriteModel(nullptr); + target.setStageModel(&stageModel); + + target.setWidth(3); + target.setHeight(3); + fbo.release(); + QOpenGLFramebufferObject emptyFbo(fbo.size(), format); + emptyFbo.bind(); + target.updateHullPoints(&emptyFbo); // clear the convex hull points list + ASSERT_TRUE(target.hullPoints().empty()); + emptyFbo.release(); + fbo.bind(); + target.updateHullPoints(&fbo); + ASSERT_EQ(target.hullPoints(), std::vector({ { 1, 1 }, { 2, 1 }, { 3, 1 }, { 1, 2 }, { 3, 2 }, { 1, 3 }, { 2, 3 }, { 3, 3 } })); + + // Stage: contains() + ASSERT_TRUE(target.contains({ 0, 0 })); + ASSERT_TRUE(target.contains({ 1, 0 })); + ASSERT_TRUE(target.contains({ 2, 0 })); + ASSERT_TRUE(target.contains({ 3, 0 })); + + ASSERT_TRUE(target.contains({ 0, 1 })); + ASSERT_TRUE(target.contains({ 1, 1 })); + ASSERT_TRUE(target.contains({ 1.4, 1.25 })); + ASSERT_TRUE(target.contains({ 2, 1 })); + ASSERT_TRUE(target.contains({ 3, 1 })); + + ASSERT_TRUE(target.contains({ 1, 2 })); + ASSERT_TRUE(target.contains({ 2, 2 })); + ASSERT_TRUE(target.contains({ 3, 2 })); + ASSERT_TRUE(target.contains({ 3.5, 2.1 })); + + ASSERT_TRUE(target.contains({ 1, 3 })); + ASSERT_TRUE(target.contains({ 2, 3 })); + ASSERT_TRUE(target.contains({ 3, 3 })); + ASSERT_TRUE(target.contains({ 3.3, 3.5 })); + + // Release + fbo.release(); + context.doneCurrent(); } TEST_F(RenderedTargetTest, SpriteDragging) @@ -633,6 +674,12 @@ TEST_F(RenderedTargetTest, GetBounds) ASSERT_EQ(std::round(bounds.right() * 100) / 100, 66.72); ASSERT_EQ(std::round(bounds.bottom() * 100) / 100, -125.11); + QRectF bubbleBounds = target.getBoundsForBubble(); + ASSERT_EQ(std::round(bubbleBounds.left() * 100) / 100, 66.13); + ASSERT_EQ(std::round(bubbleBounds.top() * 100) / 100, -124.52); + ASSERT_EQ(std::round(bubbleBounds.right() * 100) / 100, 66.72); + ASSERT_EQ(std::round(bubbleBounds.bottom() * 100) / 100, -125.11); + EXPECT_CALL(engine, stageWidth()).WillOnce(Return(480)); EXPECT_CALL(engine, stageHeight()).WillOnce(Return(360)); target.updateRotationStyle(Sprite::RotationStyle::LeftRight); @@ -643,6 +690,12 @@ TEST_F(RenderedTargetTest, GetBounds) ASSERT_EQ(std::round(bounds.right() * 100) / 100, 72.29); ASSERT_EQ(std::round(bounds.bottom() * 100) / 100, -110.89); + bubbleBounds = target.getBoundsForBubble(); + ASSERT_EQ(std::round(bubbleBounds.left() * 100) / 100, 71.87); + ASSERT_EQ(std::round(bubbleBounds.top() * 100) / 100, -110.47); + ASSERT_EQ(std::round(bubbleBounds.right() * 100) / 100, 72.29); + ASSERT_EQ(std::round(bubbleBounds.bottom() * 100) / 100, -110.89); + EXPECT_CALL(engine, stageWidth()).WillOnce(Return(480)); EXPECT_CALL(engine, stageHeight()).WillOnce(Return(360)); target.setStageScale(20.75); @@ -653,5 +706,27 @@ TEST_F(RenderedTargetTest, GetBounds) ASSERT_EQ(std::round(bounds.right() * 100) / 100, 72.29); ASSERT_EQ(std::round(bounds.bottom() * 100) / 100, -110.89); + bubbleBounds = target.getBoundsForBubble(); + ASSERT_EQ(std::round(bubbleBounds.left() * 100) / 100, 71.87); + ASSERT_EQ(std::round(bubbleBounds.top() * 100) / 100, -110.47); + ASSERT_EQ(std::round(bubbleBounds.right() * 100) / 100, 72.29); + ASSERT_EQ(std::round(bubbleBounds.bottom() * 100) / 100, -110.89); + + EXPECT_CALL(engine, stageWidth()).WillOnce(Return(480)); + EXPECT_CALL(engine, stageHeight()).WillOnce(Return(360)); + target.updateSize(9780.6); + + bounds = target.getBounds(); + ASSERT_EQ(std::round(bounds.left() * 100) / 100, -466.05); + ASSERT_EQ(std::round(bounds.top() * 100) / 100, 1294.13); + ASSERT_EQ(std::round(bounds.right() * 100) / 100, -405.87); + ASSERT_EQ(std::round(bounds.bottom() * 100) / 100, 1233.94); + + bubbleBounds = target.getBoundsForBubble(); + ASSERT_EQ(std::round(bubbleBounds.left() * 100) / 100, -466.05); + ASSERT_EQ(std::round(bubbleBounds.top() * 100) / 100, 1294.13); + ASSERT_EQ(std::round(bubbleBounds.right() * 100) / 100, -405.87); + ASSERT_EQ(std::round(bubbleBounds.bottom() * 100) / 100, 1286.13); + context.doneCurrent(); } diff --git a/test/say_bubble.png b/test/say_bubble.png new file mode 100644 index 0000000..5645c65 Binary files /dev/null and b/test/say_bubble.png differ diff --git a/test/target_models/spritemodel_test.cpp b/test/target_models/spritemodel_test.cpp index 250a504..b2eca9e 100644 --- a/test/target_models/spritemodel_test.cpp +++ b/test/target_models/spritemodel_test.cpp @@ -244,6 +244,48 @@ TEST(SpriteModelTest, OnGraphicsEffectsCleared) model.onGraphicsEffectsCleared(); } +TEST(SpriteModelTest, OnBubbleTypeChanged) +{ + SpriteModel model; + QSignalSpy spy(&model, &SpriteModel::bubbleTypeChanged); + ASSERT_EQ(model.bubbleType(), TextBubbleShape::Type::Say); + + model.onBubbleTypeChanged(Target::BubbleType::Think); + ASSERT_EQ(model.bubbleType(), TextBubbleShape::Type::Think); + ASSERT_EQ(spy.count(), 1); + + model.onBubbleTypeChanged(Target::BubbleType::Think); + ASSERT_EQ(model.bubbleType(), TextBubbleShape::Type::Think); + ASSERT_EQ(spy.count(), 1); + + model.onBubbleTypeChanged(Target::BubbleType::Say); + ASSERT_EQ(model.bubbleType(), TextBubbleShape::Type::Say); + ASSERT_EQ(spy.count(), 2); + + model.onBubbleTypeChanged(Target::BubbleType::Say); + ASSERT_EQ(model.bubbleType(), TextBubbleShape::Type::Say); + ASSERT_EQ(spy.count(), 2); +} + +TEST(SpriteModelTest, OnBubbleTextChanged) +{ + SpriteModel model; + QSignalSpy spy(&model, &SpriteModel::bubbleTextChanged); + ASSERT_TRUE(model.bubbleText().isEmpty()); + + model.onBubbleTextChanged("Hello!"); + ASSERT_EQ(model.bubbleText(), "Hello!"); + ASSERT_EQ(spy.count(), 1); + + model.onBubbleTextChanged("Hello!"); + ASSERT_EQ(model.bubbleText(), "Hello!"); + ASSERT_EQ(spy.count(), 1); + + model.onBubbleTextChanged("test"); + ASSERT_EQ(model.bubbleText(), "test"); + ASSERT_EQ(spy.count(), 2); +} + TEST(SpriteModelTest, BoundingRect) { SpriteModel model; diff --git a/test/target_models/stagemodel_test.cpp b/test/target_models/stagemodel_test.cpp index d1c292f..8d73cda 100644 --- a/test/target_models/stagemodel_test.cpp +++ b/test/target_models/stagemodel_test.cpp @@ -64,6 +64,48 @@ TEST(StageModelTest, OnGraphicsEffectsCleared) model.onGraphicsEffectsCleared(); } +TEST(StageModelTest, OnBubbleTypeChanged) +{ + StageModel model; + QSignalSpy spy(&model, &StageModel::bubbleTypeChanged); + ASSERT_EQ(model.bubbleType(), TextBubbleShape::Type::Say); + + model.onBubbleTypeChanged(Target::BubbleType::Think); + ASSERT_EQ(model.bubbleType(), TextBubbleShape::Type::Think); + ASSERT_EQ(spy.count(), 1); + + model.onBubbleTypeChanged(Target::BubbleType::Think); + ASSERT_EQ(model.bubbleType(), TextBubbleShape::Type::Think); + ASSERT_EQ(spy.count(), 1); + + model.onBubbleTypeChanged(Target::BubbleType::Say); + ASSERT_EQ(model.bubbleType(), TextBubbleShape::Type::Say); + ASSERT_EQ(spy.count(), 2); + + model.onBubbleTypeChanged(Target::BubbleType::Say); + ASSERT_EQ(model.bubbleType(), TextBubbleShape::Type::Say); + ASSERT_EQ(spy.count(), 2); +} + +TEST(StageModelTest, OnBubbleTextChanged) +{ + StageModel model; + QSignalSpy spy(&model, &StageModel::bubbleTextChanged); + ASSERT_TRUE(model.bubbleText().isEmpty()); + + model.onBubbleTextChanged("Hello!"); + ASSERT_EQ(model.bubbleText(), "Hello!"); + ASSERT_EQ(spy.count(), 1); + + model.onBubbleTextChanged("Hello!"); + ASSERT_EQ(model.bubbleText(), "Hello!"); + ASSERT_EQ(spy.count(), 1); + + model.onBubbleTextChanged("test"); + ASSERT_EQ(model.bubbleText(), "test"); + ASSERT_EQ(spy.count(), 2); +} + TEST(StageModelTest, RenderedTarget) { StageModel model; diff --git a/test/textbubblepainter/CMakeLists.txt b/test/textbubblepainter/CMakeLists.txt new file mode 100644 index 0000000..a356558 --- /dev/null +++ b/test/textbubblepainter/CMakeLists.txt @@ -0,0 +1,15 @@ +add_executable( + textbubblepainter_test + textbubblepainter_test.cpp +) + +target_link_libraries( + textbubblepainter_test + GTest::gtest_main + scratchcpp-render + ${QT_LIBS} + qnanopainter +) + +add_test(textbubblepainter_test) +gtest_discover_tests(textbubblepainter_test) diff --git a/test/textbubblepainter/textbubblepainter_test.cpp b/test/textbubblepainter/textbubblepainter_test.cpp new file mode 100644 index 0000000..739095b --- /dev/null +++ b/test/textbubblepainter/textbubblepainter_test.cpp @@ -0,0 +1,116 @@ +#include +#include + +#include "../common.h" + +using namespace scratchcpprender; + +class TextBubblePainterTest : public testing::Test +{ + public: + void createContextAndSurface(QOpenGLContext *context, QOffscreenSurface *surface) + { + QSurfaceFormat surfaceFormat; + surfaceFormat.setMajorVersion(4); + surfaceFormat.setMinorVersion(3); + + context->setFormat(surfaceFormat); + context->create(); + ASSERT_TRUE(context->isValid()); + + surface->setFormat(surfaceFormat); + surface->create(); + ASSERT_TRUE(surface->isValid()); + + context->makeCurrent(surface); + ASSERT_EQ(QOpenGLContext::currentContext(), context); + } +}; + +TEST_F(TextBubblePainterTest, PaintSayBubble) +{ + QOpenGLContext context; + QOffscreenSurface surface; + createContextAndSurface(&context, &surface); + + QOpenGLFramebufferObjectFormat format; + format.setAttachment(QOpenGLFramebufferObject::CombinedDepthStencil); + + // Begin painting + QNanoPainter painter; + QOpenGLFramebufferObject fbo(425, 250, format); + fbo.bind(); + glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + painter.beginFrame(fbo.width(), fbo.height()); + painter.setAntialias(0); + + // Create text bubble painter + TextBubblePainter bubblePainter; + TextBubbleShape bubble; + bubble.setType(TextBubbleShape::Type::Say); + bubble.setStageScale(2.5); + bubble.setNativeWidth(fbo.width() / bubble.stageScale()); + bubble.setNativeHeight(fbo.height() / bubble.stageScale()); + bubblePainter.synchronize(&bubble); + + // Paint + bubblePainter.paint(&painter); + painter.endFrame(); + + // Compare with reference image + QBuffer buffer; + fbo.toImage().save(&buffer, "png"); + QFile ref("say_bubble.png"); + ref.open(QFile::ReadOnly); + buffer.open(QFile::ReadOnly); + ASSERT_EQ(ref.readAll(), buffer.readAll()); + + // Release + fbo.release(); + context.doneCurrent(); +} + +TEST_F(TextBubblePainterTest, PaintThinkBubble) +{ + QOpenGLContext context; + QOffscreenSurface surface; + createContextAndSurface(&context, &surface); + + QOpenGLFramebufferObjectFormat format; + format.setAttachment(QOpenGLFramebufferObject::CombinedDepthStencil); + + // Begin painting + QNanoPainter painter; + QOpenGLFramebufferObject fbo(156, 117, format); + fbo.bind(); + glClearColor(0.0f, 0.0f, 0.0f, 0.0f); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + painter.beginFrame(fbo.width(), fbo.height()); + painter.setAntialias(0); + + // Create text bubble painter + TextBubblePainter bubblePainter; + TextBubbleShape bubble; + bubble.setType(TextBubbleShape::Type::Think); + bubble.setStageScale(1.25); + bubble.setNativeWidth(fbo.width() / bubble.stageScale()); + bubble.setNativeHeight(fbo.height() / bubble.stageScale()); + bubblePainter.synchronize(&bubble); + + // Paint + bubblePainter.paint(&painter); + painter.endFrame(); + + // Compare with reference image + QBuffer buffer; + fbo.toImage().save(&buffer, "png"); + QFile ref("think_bubble.png"); + ref.open(QFile::ReadOnly); + buffer.open(QFile::ReadOnly); + ASSERT_EQ(ref.readAll(), buffer.readAll()); + + // Release + fbo.release(); + context.doneCurrent(); +} diff --git a/test/textbubbleshape/CMakeLists.txt b/test/textbubbleshape/CMakeLists.txt new file mode 100644 index 0000000..7edf397 --- /dev/null +++ b/test/textbubbleshape/CMakeLists.txt @@ -0,0 +1,14 @@ +add_executable( + textbubbleshape_test + textbubbleshape_test.cpp +) + +target_link_libraries( + textbubbleshape_test + GTest::gtest_main + scratchcpp-render + ${QT_LIBS} +) + +add_test(textbubbleshape_test) +gtest_discover_tests(textbubbleshape_test) diff --git a/test/textbubbleshape/textbubbleshape_test.cpp b/test/textbubbleshape/textbubbleshape_test.cpp new file mode 100644 index 0000000..bc6d9c2 --- /dev/null +++ b/test/textbubbleshape/textbubbleshape_test.cpp @@ -0,0 +1,96 @@ +#include +#include +#include + +#include "../common.h" + +using namespace scratchcpprender; + +class TextBubbleShapeTest : public testing::Test +{ + public: + void SetUp() override + { + m_context.create(); + ASSERT_TRUE(m_context.isValid()); + + m_surface.setFormat(m_context.format()); + m_surface.create(); + Q_ASSERT(m_surface.isValid()); + m_context.makeCurrent(&m_surface); + } + + void TearDown() override + { + ASSERT_EQ(m_context.surface(), &m_surface); + m_context.doneCurrent(); + } + + QOpenGLContext m_context; + QOffscreenSurface m_surface; +}; + +TEST_F(TextBubbleShapeTest, Constructors) +{ + TextBubbleShape bubble1; + TextBubbleShape bubble2(&bubble1); + ASSERT_EQ(bubble2.parent(), &bubble1); + ASSERT_EQ(bubble2.parentItem(), &bubble1); +} + +TEST_F(TextBubbleShapeTest, Type) +{ + TextBubbleShape bubble; + ASSERT_EQ(bubble.type(), TextBubbleShape::Type::Say); + + bubble.setType(TextBubbleShape::Type::Think); + ASSERT_EQ(bubble.type(), TextBubbleShape::Type::Think); + + bubble.setType(TextBubbleShape::Type::Think); + ASSERT_EQ(bubble.type(), TextBubbleShape::Type::Think); + + bubble.setType(TextBubbleShape::Type::Say); + ASSERT_EQ(bubble.type(), TextBubbleShape::Type::Say); +} + +TEST_F(TextBubbleShapeTest, OnSpriteRight) +{ + TextBubbleShape bubble; + ASSERT_TRUE(bubble.onSpriteRight()); + + bubble.setOnSpriteRight(false); + ASSERT_FALSE(bubble.onSpriteRight()); + + bubble.setOnSpriteRight(false); + ASSERT_FALSE(bubble.onSpriteRight()); + + bubble.setOnSpriteRight(true); + ASSERT_TRUE(bubble.onSpriteRight()); +} + +TEST_F(TextBubbleShapeTest, StageScale) +{ + TextBubbleShape bubble; + ASSERT_EQ(bubble.stageScale(), 1); + + bubble.setStageScale(6.48); + ASSERT_EQ(bubble.stageScale(), 6.48); +} + +TEST_F(TextBubbleShapeTest, NativeWidth) +{ + TextBubbleShape bubble; + ASSERT_EQ(bubble.nativeWidth(), 0); + + bubble.setNativeWidth(48.1); + ASSERT_EQ(bubble.nativeWidth(), 48.1); +} + +TEST_F(TextBubbleShapeTest, NativeHeight) +{ + TextBubbleShape bubble; + ASSERT_EQ(bubble.nativeHeight(), 0); + + bubble.setNativeHeight(87.5); + ASSERT_EQ(bubble.nativeHeight(), 87.5); +} diff --git a/test/think_bubble.png b/test/think_bubble.png new file mode 100644 index 0000000..860ee52 Binary files /dev/null and b/test/think_bubble.png differ