diff --git a/include/scratchcpp/iengine.h b/include/scratchcpp/iengine.h index b11bb684..8d2b394d 100644 --- a/include/scratchcpp/iengine.h +++ b/include/scratchcpp/iengine.h @@ -338,6 +338,18 @@ class LIBSCRATCHCPP_EXPORT IEngine /*! Sets the function which is called when a monitor is removed. */ virtual void setRemoveMonitorHandler(const std::function &handler) = 0; + /*! Returns the function which is called when a question is asked, for example using the 'ask and wait' block. */ + virtual const std::function &questionAsked() const = 0; + + /*! Sets the function which is called when a question is asked, for example using the 'ask and wait' block. */ + virtual void setQuestionAsked(const std::function &f) = 0; + + /*! Returns the function which should be called when a question is answered. */ + virtual const std::function &questionAnswered() const = 0; + + /*! Sets the function which should be called when a question is answered. */ + virtual void setQuestionAnswered(const std::function &f) = 0; + /*! Returns the list of extension names. */ virtual const std::vector &extensions() const = 0; diff --git a/src/blocks/sensingblocks.cpp b/src/blocks/sensingblocks.cpp index 0af82ae8..3ac1a5d4 100644 --- a/src/blocks/sensingblocks.cpp +++ b/src/blocks/sensingblocks.cpp @@ -28,6 +28,8 @@ void SensingBlocks::registerBlocks(IEngine *engine) { // Blocks engine->addCompileFunction(this, "sensing_distanceto", &compileDistanceTo); + engine->addCompileFunction(this, "sensing_askandwait", &compileAskAndWait); + engine->addCompileFunction(this, "sensing_answer", &compileAnswer); engine->addCompileFunction(this, "sensing_keypressed", &compileKeyPressed); engine->addCompileFunction(this, "sensing_mousedown", &compileMouseDown); engine->addCompileFunction(this, "sensing_mousex", &compileMouseX); @@ -77,6 +79,9 @@ void SensingBlocks::registerBlocks(IEngine *engine) engine->addFieldValue(this, "background #", BackdropNumber); // Scratch 1.4 support engine->addFieldValue(this, "backdrop #", BackdropNumber); engine->addFieldValue(this, "backdrop name", BackdropName); + + // Callbacks + engine->setQuestionAnswered(&onAnswer); } void SensingBlocks::compileDistanceTo(Compiler *compiler) @@ -100,6 +105,17 @@ void SensingBlocks::compileDistanceTo(Compiler *compiler) } } +void SensingBlocks::compileAskAndWait(Compiler *compiler) +{ + compiler->addInput(QUESTION); + compiler->addFunctionCall(&askAndWait); +} + +void SensingBlocks::compileAnswer(Compiler *compiler) +{ + compiler->addFunctionCall(&answer); +} + void SensingBlocks::compileKeyPressed(Compiler *compiler) { compiler->addInput(KEY_OPTION); @@ -488,6 +504,45 @@ unsigned int SensingBlocks::distanceToMousePointer(VirtualMachine *vm) return 0; } +void SensingBlocks::onAnswer(const std::string &answer) +{ + // https://github.com/scratchfoundation/scratch-vm/blob/6055823f203a696165084b873e661713806583ec/src/blocks/scratch3_sensing.js#L99-L115 + m_answer = answer; + + if (!m_questionList.empty()) { + Question *question = m_questionList.front().get(); + VirtualMachine *vm = question->vm; + assert(vm); + assert(vm->target()); + + // If the target was visible when asked, hide the say bubble unless the target was the stage + if (question->wasVisible && !question->wasStage) + vm->target()->setBubbleText(""); + + m_questionList.erase(m_questionList.begin()); + vm->resolvePromise(); + askNextQuestion(); + } +} + +unsigned int SensingBlocks::askAndWait(VirtualMachine *vm) +{ + const bool isQuestionAsked = !m_questionList.empty(); + enqueueAsk(vm->getInput(0, 1)->toString(), vm); + + if (!isQuestionAsked) + askNextQuestion(); + + vm->promise(); + return 1; +} + +unsigned int SensingBlocks::answer(VirtualMachine *vm) +{ + vm->addReturnValue(m_answer); + return 0; +} + unsigned int SensingBlocks::timer(VirtualMachine *vm) { vm->addReturnValue(vm->engine()->timer()->value()); @@ -822,3 +877,44 @@ unsigned int SensingBlocks::daysSince2000(VirtualMachine *vm) return 0; } + +void SensingBlocks::enqueueAsk(const std::string &question, VirtualMachine *vm) +{ + // https://github.com/scratchfoundation/scratch-vm/blob/6055823f203a696165084b873e661713806583ec/src/blocks/scratch3_sensing.js#L117-L119 + assert(vm); + Target *target = vm->target(); + assert(target); + bool visible = true; + bool isStage = target->isStage(); + + if (!isStage) { + Sprite *sprite = static_cast(target); + visible = sprite->visible(); + } + + m_questionList.push_back(std::make_unique(question, vm, visible, isStage)); +} + +void SensingBlocks::askNextQuestion() +{ + // https://github.com/scratchfoundation/scratch-vm/blob/6055823f203a696165084b873e661713806583ec/src/blocks/scratch3_sensing.js#L121-L133 + if (m_questionList.empty()) + return; + + Question *question = m_questionList.front().get(); + Target *target = question->vm->target(); + auto ask = question->vm->engine()->questionAsked(); + + // If the target is visible, emit a blank question and show + // a bubble unless the target was the stage + if (question->wasVisible && !question->wasStage) { + target->setBubbleType(Target::BubbleType::Say); + target->setBubbleText(question->question); + + if (ask) + ask(""); + } else { + if (ask) + ask(question->question); + } +} diff --git a/src/blocks/sensingblocks.h b/src/blocks/sensingblocks.h index b79e3ca6..01d8a608 100644 --- a/src/blocks/sensingblocks.h +++ b/src/blocks/sensingblocks.h @@ -3,11 +3,16 @@ #pragma once #include +#include +#include + #include "../engine/internal/clock.h" namespace libscratchcpp { +class Target; + /*! \brief The SensingBlocks class contains the implementation of sensing blocks. */ class SensingBlocks : public IBlockSection { @@ -15,6 +20,7 @@ class SensingBlocks : public IBlockSection enum Inputs { DISTANCETOMENU, + QUESTION, KEY_OPTION, OBJECT }; @@ -53,6 +59,8 @@ class SensingBlocks : public IBlockSection void registerBlocks(IEngine *engine) override; static void compileDistanceTo(Compiler *compiler); + static void compileAskAndWait(Compiler *compiler); + static void compileAnswer(Compiler *compiler); static void compileKeyPressed(Compiler *compiler); static void compileMouseDown(Compiler *compiler); static void compileMouseX(Compiler *compiler); @@ -83,6 +91,10 @@ class SensingBlocks : public IBlockSection static unsigned int distanceToByIndex(VirtualMachine *vm); static unsigned int distanceToMousePointer(VirtualMachine *vm); + static void onAnswer(const std::string &answer); + static unsigned int askAndWait(VirtualMachine *vm); + static unsigned int answer(VirtualMachine *vm); + static unsigned int timer(VirtualMachine *vm); static unsigned int resetTimer(VirtualMachine *vm); @@ -116,6 +128,29 @@ class SensingBlocks : public IBlockSection static unsigned int daysSince2000(VirtualMachine *vm); static IClock *clock; + + private: + struct Question + { + Question(const std::string &question, VirtualMachine *vm, bool wasVisible, bool wasStage) : + question(question), + vm(vm), + wasVisible(wasVisible), + wasStage(wasStage) + { + } + + std::string question; + VirtualMachine *vm = nullptr; + bool wasVisible = false; + bool wasStage = false; + }; + + static void enqueueAsk(const std::string &question, VirtualMachine *vm); + static void askNextQuestion(); + + static inline std::vector> m_questionList; + static inline Value m_answer; }; } // namespace libscratchcpp diff --git a/src/engine/internal/engine.cpp b/src/engine/internal/engine.cpp index bbbd44a1..309c11ec 100644 --- a/src/engine/internal/engine.cpp +++ b/src/engine/internal/engine.cpp @@ -1170,6 +1170,26 @@ void Engine::setRemoveMonitorHandler(const std::function &Engine::questionAsked() const +{ + return m_questionAsked; +} + +void Engine::setQuestionAsked(const std::function &f) +{ + m_questionAsked = f; +} + +const std::function &Engine::questionAnswered() const +{ + return m_questionAnswered; +} + +void Engine::setQuestionAnswered(const std::function &f) +{ + m_questionAnswered = f; +} + const std::vector &Engine::extensions() const { return m_extensions; diff --git a/src/engine/internal/engine.h b/src/engine/internal/engine.h index 6b7c1aaf..34b593cb 100644 --- a/src/engine/internal/engine.h +++ b/src/engine/internal/engine.h @@ -143,6 +143,12 @@ class Engine : public IEngine void setAddMonitorHandler(const std::function &handler) override; void setRemoveMonitorHandler(const std::function &handler) override; + const std::function &questionAsked() const override; + void setQuestionAsked(const std::function &f) override; + + const std::function &questionAnswered() const override; + void setQuestionAnswered(const std::function &f) override; + const std::vector &extensions() const override; void setExtensions(const std::vector &newExtensions) override; @@ -255,6 +261,8 @@ class Engine : public IEngine std::function m_addMonitorHandler = nullptr; std::function m_removeMonitorHandler = nullptr; + std::function m_questionAsked = nullptr; + std::function m_questionAnswered = nullptr; }; } // namespace libscratchcpp diff --git a/src/scratch/sprite.cpp b/src/scratch/sprite.cpp index 8db496c8..609241af 100644 --- a/src/scratch/sprite.cpp +++ b/src/scratch/sprite.cpp @@ -441,7 +441,7 @@ void Sprite::setBubbleType(BubbleType type) /*! Overrides Target#setBubbleText(). */ void Sprite::setBubbleText(const std::string &text) { - Target::setBubbleText(text); + Target::setBubbleText(impl->visible ? text : ""); if (impl->visible && !text.empty()) { IEngine *eng = engine(); @@ -451,7 +451,7 @@ void Sprite::setBubbleText(const std::string &text) } if (impl->iface) - impl->iface->onBubbleTextChanged(text); + impl->iface->onBubbleTextChanged(impl->visible ? text : ""); } Target *Sprite::dataSource() const diff --git a/test/blocks/sensing_blocks_test.cpp b/test/blocks/sensing_blocks_test.cpp index ddeb5a31..3cd14b76 100644 --- a/test/blocks/sensing_blocks_test.cpp +++ b/test/blocks/sensing_blocks_test.cpp @@ -18,6 +18,9 @@ using namespace libscratchcpp; using ::testing::Return; +using ::testing::ReturnRef; +using ::testing::SaveArg; +using ::testing::_; class SensingBlocksTest : public testing::Test { @@ -92,6 +95,19 @@ class SensingBlocksTest : public testing::Test ClockMock m_clockMock; }; +struct QuestionSpy +{ + MOCK_METHOD(void, asked, (const std::string &), ()); +}; + +template +size_t getAddress(std::function f) +{ + typedef T(fnType)(U...); + fnType **fnPointer = f.template target(); + return (size_t)*fnPointer; +} + TEST_F(SensingBlocksTest, Name) { ASSERT_EQ(m_section->name(), "Sensing"); @@ -106,6 +122,8 @@ TEST_F(SensingBlocksTest, RegisterBlocks) { // Blocks EXPECT_CALL(m_engineMock, addCompileFunction(m_section.get(), "sensing_distanceto", &SensingBlocks::compileDistanceTo)); + EXPECT_CALL(m_engineMock, addCompileFunction(m_section.get(), "sensing_askandwait", &SensingBlocks::compileAskAndWait)); + EXPECT_CALL(m_engineMock, addCompileFunction(m_section.get(), "sensing_answer", &SensingBlocks::compileAnswer)); EXPECT_CALL(m_engineMock, addCompileFunction(m_section.get(), "sensing_keypressed", &SensingBlocks::compileKeyPressed)); EXPECT_CALL(m_engineMock, addCompileFunction(m_section.get(), "sensing_mousedown", &SensingBlocks::compileMouseDown)); EXPECT_CALL(m_engineMock, addCompileFunction(m_section.get(), "sensing_mousex", &SensingBlocks::compileMouseX)); @@ -156,7 +174,14 @@ TEST_F(SensingBlocksTest, RegisterBlocks) EXPECT_CALL(m_engineMock, addFieldValue(m_section.get(), "backdrop #", SensingBlocks::BackdropNumber)); EXPECT_CALL(m_engineMock, addFieldValue(m_section.get(), "backdrop name", SensingBlocks::BackdropName)); + // Callbacks + std::function questionAnsweredRef = &SensingBlocks::onAnswer; + std::function questionAnswered; + EXPECT_CALL(m_engineMock, setQuestionAnswered(_)).WillOnce(SaveArg<0>(&questionAnswered)); + m_section->registerBlocks(&m_engineMock); + ASSERT_TRUE(questionAnswered); + ASSERT_EQ(getAddress(questionAnsweredRef), getAddress(questionAnswered)); } TEST_F(SensingBlocksTest, DistanceTo) @@ -290,6 +315,162 @@ TEST_F(SensingBlocksTest, DistanceToImpl) ASSERT_EQ(std::round(vm.getInput(0, 1)->toDouble() * 10000) / 10000, 261.0096); } +TEST_F(SensingBlocksTest, AskAndWait) +{ + Compiler compiler(&m_engineMock); + + // ask "test" and wait + auto block1 = std::make_shared("a", "sensing_askandwait"); + addDropdownInput(block1, "QUESTION", SensingBlocks::QUESTION, "test"); + + // ask (null block) and wait + auto block2 = std::make_shared("b", "sensing_askandwait"); + addDropdownInput(block2, "QUESTION", SensingBlocks::QUESTION, "", createNullBlock("c")); + + compiler.init(); + + EXPECT_CALL(m_engineMock, functionIndex(&SensingBlocks::askAndWait)).WillOnce(Return(0)); + compiler.setBlock(block1); + SensingBlocks::compileAskAndWait(&compiler); + + EXPECT_CALL(m_engineMock, functionIndex(&SensingBlocks::askAndWait)).WillOnce(Return(0)); + compiler.setBlock(block2); + SensingBlocks::compileAskAndWait(&compiler); + + compiler.end(); + + ASSERT_EQ(compiler.bytecode(), std::vector({ vm::OP_START, vm::OP_CONST, 0, vm::OP_EXEC, 0, vm::OP_NULL, vm::OP_EXEC, 0, vm::OP_HALT })); + ASSERT_EQ(compiler.constValues().size(), 1); + ASSERT_EQ(compiler.constValues()[0].toString(), "test"); +} + +TEST_F(SensingBlocksTest, Answer) +{ + Compiler compiler(&m_engineMock); + + auto block = std::make_shared("a", "sensing_answer"); + + compiler.init(); + + EXPECT_CALL(m_engineMock, functionIndex(&SensingBlocks::answer)).WillOnce(Return(0)); + compiler.setBlock(block); + SensingBlocks::compileAnswer(&compiler); + + compiler.end(); + + ASSERT_EQ(compiler.bytecode(), std::vector({ vm::OP_START, vm::OP_EXEC, 0, vm::OP_HALT })); + ASSERT_TRUE(compiler.constValues().empty()); +} + +TEST_F(SensingBlocksTest, AskAndWaitAndAnswerImpl) +{ + static unsigned int bytecode1[] = { vm::OP_START, vm::OP_CONST, 0, vm::OP_EXEC, 0, vm::OP_HALT }; + static unsigned int bytecode2[] = { vm::OP_START, vm::OP_CONST, 1, vm::OP_EXEC, 0, vm::OP_HALT }; + static unsigned int bytecode3[] = { vm::OP_START, vm::OP_CONST, 2, vm::OP_EXEC, 0, vm::OP_HALT }; + static unsigned int bytecode4[] = { vm::OP_START, vm::OP_EXEC, 1, vm::OP_HALT }; + static BlockFunc functions[] = { &SensingBlocks::askAndWait, &SensingBlocks::answer }; + static Value constValues[] = { "test1", "test2", "test3" }; + + Sprite sprite; + sprite.setBubbleType(Target::BubbleType::Think); + Stage stage; + QuestionSpy spy; + std::function asked = std::bind(&QuestionSpy::asked, &spy, std::placeholders::_1); + + VirtualMachine vm1(&sprite, &m_engineMock, nullptr); + vm1.setFunctions(functions); + vm1.setConstValues(constValues); + + // Ask 3 questions (2 where the sprite is visible and 1 where it's invisible) + EXPECT_CALL(m_engineMock, questionAsked()).WillOnce(ReturnRef(asked)); + EXPECT_CALL(spy, asked("")); + sprite.setVisible(true); + vm1.setBytecode(bytecode1); + vm1.run(); + + ASSERT_EQ(vm1.registerCount(), 0); + ASSERT_FALSE(vm1.atEnd()); + ASSERT_EQ(sprite.bubbleType(), Target::BubbleType::Say); + ASSERT_EQ(sprite.bubbleText(), "test1"); + + EXPECT_CALL(m_engineMock, questionAsked).Times(0); + vm1.reset(); + vm1.setBytecode(bytecode2); + vm1.run(); + + ASSERT_EQ(vm1.registerCount(), 0); + ASSERT_FALSE(vm1.atEnd()); + ASSERT_EQ(sprite.bubbleType(), Target::BubbleType::Say); + ASSERT_EQ(sprite.bubbleText(), "test1"); + + EXPECT_CALL(m_engineMock, questionAsked).Times(0); + sprite.setVisible(false); + vm1.reset(); + vm1.setBytecode(bytecode3); + vm1.run(); + sprite.setVisible(true); + + ASSERT_EQ(vm1.registerCount(), 0); + ASSERT_FALSE(vm1.atEnd()); + ASSERT_EQ(sprite.bubbleType(), Target::BubbleType::Say); + ASSERT_EQ(sprite.bubbleText(), "test1"); + + // Ask a question from the stage + VirtualMachine vm2(&stage, &m_engineMock, nullptr); + vm2.setFunctions(functions); + vm2.setConstValues(constValues); + + EXPECT_CALL(m_engineMock, questionAsked).Times(0); + vm2.setBytecode(bytecode2); + vm2.run(); + ASSERT_EQ(vm2.registerCount(), 0); + ASSERT_FALSE(vm2.atEnd()); + ASSERT_EQ(sprite.bubbleType(), Target::BubbleType::Say); + ASSERT_EQ(sprite.bubbleText(), "test1"); + + // Answer the questions + EXPECT_CALL(m_engineMock, questionAsked()).WillOnce(ReturnRef(asked)); + EXPECT_CALL(spy, asked("")); + SensingBlocks::onAnswer("hi"); + ASSERT_EQ(sprite.bubbleType(), Target::BubbleType::Say); + ASSERT_EQ(sprite.bubbleText(), "test2"); + + vm1.reset(); + vm1.setBytecode(bytecode4); + vm1.run(); + ASSERT_EQ(vm1.registerCount(), 1); + ASSERT_EQ(vm1.getInput(0, 1)->toString(), "hi"); + + EXPECT_CALL(m_engineMock, questionAsked()).WillOnce(ReturnRef(asked)); + EXPECT_CALL(spy, asked("test3")); + SensingBlocks::onAnswer("hello"); + ASSERT_TRUE(sprite.bubbleText().empty()); + + vm1.reset(); + vm1.run(); + ASSERT_EQ(vm1.registerCount(), 1); + ASSERT_EQ(vm1.getInput(0, 1)->toString(), "hello"); + + EXPECT_CALL(m_engineMock, questionAsked()).WillOnce(ReturnRef(asked)); + EXPECT_CALL(spy, asked("test2")); + SensingBlocks::onAnswer("world"); + ASSERT_TRUE(sprite.bubbleText().empty()); + ASSERT_TRUE(stage.bubbleText().empty()); + + vm1.reset(); + vm1.run(); + ASSERT_EQ(vm1.registerCount(), 1); + ASSERT_EQ(vm1.getInput(0, 1)->toString(), "world"); + + EXPECT_CALL(m_engineMock, questionAsked).Times(0); + SensingBlocks::onAnswer("test"); + + vm1.reset(); + vm1.run(); + ASSERT_EQ(vm1.registerCount(), 1); + ASSERT_EQ(vm1.getInput(0, 1)->toString(), "test"); +} + TEST_F(SensingBlocksTest, KeyPressed) { Compiler compiler(&m_engineMock); diff --git a/test/engine/engine_test.cpp b/test/engine/engine_test.cpp index 0be1a913..cfa8355f 100644 --- a/test/engine/engine_test.cpp +++ b/test/engine/engine_test.cpp @@ -51,6 +51,14 @@ class AddRemoveMonitorMock MOCK_METHOD(void, monitorRemoved, (Monitor *, IMonitorHandler *)); }; +template +size_t getAddress(std::function f) +{ + typedef T(fnType)(U...); + fnType **fnPointer = f.template target(); + return (size_t)*fnPointer; +} + TEST(EngineTest, Clock) { Engine engine; @@ -1542,6 +1550,30 @@ TEST(EngineTest, CreateMissingMonitors) } } +void questionFunction(const std::string &) +{ +} + +TEST(EngineTest, QuestionAsked) +{ + Engine engine; + ASSERT_EQ(engine.questionAsked(), nullptr); + + static const std::function f = &questionFunction; + engine.setQuestionAsked(&questionFunction); + ASSERT_EQ(getAddress(engine.questionAsked()), getAddress(f)); +} + +TEST(EngineTest, QuestionAnswered) +{ + Engine engine; + ASSERT_EQ(engine.questionAnswered(), nullptr); + + static const std::function f = &questionFunction; + engine.setQuestionAnswered(f); + ASSERT_EQ(getAddress(engine.questionAnswered()), getAddress(f)); +} + TEST(EngineTest, Clones) { Project p("clones.sb3"); diff --git a/test/mocks/enginemock.h b/test/mocks/enginemock.h index 2fa1c0ae..8f80d359 100644 --- a/test/mocks/enginemock.h +++ b/test/mocks/enginemock.h @@ -124,6 +124,12 @@ class EngineMock : public IEngine MOCK_METHOD(void, setAddMonitorHandler, (const std::function &), (override)); MOCK_METHOD(void, setRemoveMonitorHandler, (const std::function &), (override)); + MOCK_METHOD(const std::function &, questionAsked, (), (const, override)); + MOCK_METHOD(void, setQuestionAsked, (const std::function &), (override)); + + MOCK_METHOD(const std::function &, questionAnswered, (), (const, override)); + MOCK_METHOD(void, setQuestionAnswered, (const std::function &), (override)); + MOCK_METHOD(std::vector &, extensions, (), (const, override)); MOCK_METHOD(void, setExtensions, (const std::vector &), (override)); diff --git a/test/scratch_classes/sprite_test.cpp b/test/scratch_classes/sprite_test.cpp index 8abec5ec..0d9f1067 100644 --- a/test/scratch_classes/sprite_test.cpp +++ b/test/scratch_classes/sprite_test.cpp @@ -636,8 +636,12 @@ TEST(SpriteTest, GraphicsEffects) TEST(SpriteTest, BubbleType) { Sprite sprite; + EngineMock engine; + sprite.setEngine(&engine); ASSERT_EQ(sprite.bubbleType(), Target::BubbleType::Say); + EXPECT_CALL(engine, requestRedraw).Times(0); + sprite.setBubbleType(Target::BubbleType::Think); ASSERT_EQ(sprite.bubbleType(), Target::BubbleType::Think); @@ -648,11 +652,22 @@ TEST(SpriteTest, BubbleType) TEST(SpriteTest, BubbleText) { Sprite sprite; + EngineMock engine; + sprite.setVisible(true); + sprite.setEngine(&engine); ASSERT_TRUE(sprite.bubbleText().empty()); + EXPECT_CALL(engine, requestRedraw()); sprite.setBubbleText("hello"); ASSERT_EQ(sprite.bubbleText(), "hello"); + EXPECT_CALL(engine, requestRedraw()); sprite.setBubbleText("world"); ASSERT_EQ(sprite.bubbleText(), "world"); + + sprite.setVisible(false); + + EXPECT_CALL(engine, requestRedraw).Times(0); + sprite.setBubbleText("test"); + ASSERT_TRUE(sprite.bubbleText().empty()); }