diff --git a/.github/actions/install-dependencies-composite-action/action.yml b/.github/actions/install-dependencies-composite-action/action.yml index 104079ae..e48357c2 100644 --- a/.github/actions/install-dependencies-composite-action/action.yml +++ b/.github/actions/install-dependencies-composite-action/action.yml @@ -12,6 +12,10 @@ runs: shell: bash run: sudo apt-get update -y && sudo apt-get install -y cmake + - name: Install Ninja + shell: bash + run: sudo apt-get install -y ninja-build + - name: Install Qt uses: jurplel/install-qt-action@v4 with: @@ -22,4 +26,37 @@ runs: - name: Install OpenAL shell: bash - run: sudo apt-get install -y libopenal-dev \ No newline at end of file + run: sudo apt-get install -y libopenal-dev + + - name: Install GTest + shell: bash + run: sudo apt-get install -y libgtest-dev + + - name: Install gcovr + shell: bash + run: sudo apt-get install -y gcovr + + - name: Install xvbf + shell: bash + run: sudo apt-get install -y xvfb libxcb-cursor0 + + - name: Install AnyRPC + shell: bash + run: | + git clone https://github.com/sgieseking/anyrpc.git + cd anyrpc + mkdir build + cd build + cmake -DBUILD_EXAMPLES=OFF -DBUILD_WITH_LOG4CPLUS=OFF -DBUILD_PROTOCOL_MESSAGEPACK=OFF .. + cmake --build . + sudo cmake --install . + + - name: Install Spix + shell: bash + run: | + git clone https://github.com/faaxm/spix + cd spix + mkdir build && cd build + cmake -DSPIX_QT_MAJOR=6 -SPIX_BUILD_EXAMPLES=OFF .. + cmake --build . + sudo cmake --install . \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6e3e6285..ccd5c2e9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,10 +1,39 @@ -name: Build master +name: Build and test on: push: branches: [ master ] + paths: + - 'data/**/*.js' + - 'Engine/**' + - 'ImagePlugin/**' + - 'include/**' + - 'QM/**' + - 'QML/**' + - 'Ranger/**' + - 'tests/**' + - 'tools/**' + - 'World/**' + - 'CMakeLists.txt' + - '**/CMakeLists.txt' + - 'opensr_setup.sh' + pull_request: branches: [ master ] + paths: + - 'data/**/*.js' + - 'Engine/**' + - 'ImagePlugin/**' + - 'include/**' + - 'QM/**' + - 'QML/**' + - 'Ranger/**' + - 'tests/**' + - 'tools/**' + - 'World/**' + - 'CMakeLists.txt' + - '**/CMakeLists.txt' + - 'opensr_setup.sh' env: BUILD_TYPE: Debug @@ -21,7 +50,13 @@ jobs: uses: ./.github/actions/install-dependencies-composite-action - name: Configure CMake - run: cmake -S . -B build -DBUILD_ALL_TOOLS=Yes -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} + run: cmake -G Ninja -S . -B build -DBUILD_ALL_TOOLS=Yes -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} -DBUILD_TESTS=Yes - name: Build - run: cmake --build build -j + run: cmake --build build --parallel $(nproc) + + - name: Create symlinks to plugins + run: ./opensr_setup.sh -t demo + + - name: Run tests + run: cd build/ && xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" ctest -VV diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 00000000..d60bcf7c --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,66 @@ +name: Code coverage + +on: + push: + branches: [ master ] + paths: + - 'data/**/*.js' + - 'Engine/**' + - 'ImagePlugin/**' + - 'include/**' + - 'QM/**' + - 'QML/**' + - 'Ranger/**' + - 'tests/**' + - 'tools/**' + - 'World/**' + - 'CMakeLists.txt' + - '**/CMakeLists.txt' + + pull_request: + branches: [ master ] + paths: + - 'data/**/*.js' + - 'Engine/**' + - 'ImagePlugin/**' + - 'include/**' + - 'QM/**' + - 'QML/**' + - 'Ranger/**' + - 'tests/**' + - 'tools/**' + - 'World/**' + - 'CMakeLists.txt' + - '**/CMakeLists.txt' + +env: + BUILD_TYPE: Debug + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install dependencies + uses: ./.github/actions/install-dependencies-composite-action + + - name: Configure CMake + run: cmake -G Ninja -S . -B build -DCMAKE_CXX_COMPILER=g++ -DBUILD_ALL_TOOLS=Yes -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} -DENABLE_COVERAGE=Yes + + - name: Build + run: cmake --build build --parallel $(nproc) + + - name: Create symlinks to plugins + run: ./opensr_setup.sh -t demo + + - name: Make coverage report + run: xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" cmake --build build --target global_coverage + + - name: Coveralls + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + file: build/coverage/coverage.json diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5d778903..9a5c163d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -25,7 +25,7 @@ jobs: uses: ./.github/actions/install-dependencies-composite-action - name: Configure CMake - run: cmake -S . -B build -DBUILD_ALL_TOOLS=Yes -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} + run: cmake -G Ninja -S . -B build -DCMAKE_CXX_COMPILER=clang++ -DBUILD_ALL_TOOLS=Yes -DBUILD_TESTS=Yes -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} - uses: cpp-linter/cpp-linter-action@v2 id: linter @@ -34,7 +34,7 @@ jobs: with: version: '19' database: 'build' - ignore: 'QML|data|.github|build|Engine/3rdparty|Engine/shaders|tools' + ignore: 'QML|data|.github|build|Engine/3rdparty|Engine/shaders|anyrpc|spix' style: 'file' tidy-checks: '' step-summary: true diff --git a/.gitignore b/.gitignore index 7718551e..cd1afdfd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ Makefile CMakeFiles *.user CMakeCache.txt +/.vscode +/.qt # Root build dir /build /libworld.so diff --git a/CMakeLists.txt b/CMakeLists.txt index b63e802d..accf26ab 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,12 +1,10 @@ -cmake_minimum_required(VERSION 3.16...3.27) +cmake_minimum_required(VERSION 3.10) project(OpenSR VERSION 0.1.1 LANGUAGES CXX ) -list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake/Modules") - set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_CXX_STANDARD 20) @@ -19,13 +17,45 @@ set(CMAKE_AUTOUIC ON) set(API_VERSION 1) -find_package(Qt6 REQUIRED COMPONENTS Core Gui) - option(BUILD_ALL_TOOLS "Build all tools (overrides individual tool options)" OFF) option(BUILD_RESOURCE_VIEWER "Build Resource Viewer tool" OFF) option(BUILD_PLANET_VIEWER "Build Planet Viewer tool" OFF) option(BUILD_QUEST_PLAYER "Build Quest Player tool" OFF) option(BUILD_DAT_TOOLS "Build tools for DAT files" OFF) +option(BUILD_TESTS "Build project unit tests and tests for UI components" OFF) +option(ENABLE_COVERAGE "Enable code covereage (turns test building on)" OFF) + +if(ENABLE_COVERAGE) + set(BUILD_TESTS ON) + + add_compile_options(--coverage -g -O0 -fprofile-arcs) + add_link_options(--coverage -fprofile-arcs) + + find_program(GCOVR gcovr REQUIRED) + + file(MAKE_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/coverage) + + add_custom_target(run_all_tests + COMMAND find ${CMAKE_BINARY_DIR} -name "*.gcda" -delete + COMMAND ${CMAKE_CTEST_COMMAND} -VV + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + COMMENT "Running all tests for coverage" + ) + + add_custom_target(global_coverage + COMMAND ${GCOVR} -r ${CMAKE_SOURCE_DIR} + --exclude '.*/tests' + --exclude '.*/moc_.*' + --exclude '.*autogen.*' + --exclude '.*_autogen.*' + --exclude '.*/CMakeFiles/.*' + --exclude '.*/.qt' + --coveralls -o ${CMAKE_BINARY_DIR}/coverage/coverage.json + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + COMMENT "Generating combined coverage report for all tests" + DEPENDS run_all_tests + ) +endif() if(BUILD_ALL_TOOLS) set(BUILD_RESOURCE_VIEWER ON) @@ -40,6 +70,11 @@ add_subdirectory(QM) add_subdirectory(Engine) add_subdirectory(World) +if (BUILD_TESTS) + include(CTest) + add_subdirectory(tests) +endif() + if(BUILD_RESOURCE_VIEWER OR BUILD_PLANET_VIEWER OR BUILD_QUEST_PLAYER OR BUILD_DAT_TOOLS) add_subdirectory(tools) endif() diff --git a/Engine/CMakeLists.txt b/Engine/CMakeLists.txt index 24ce09fc..058e56ed 100644 --- a/Engine/CMakeLists.txt +++ b/Engine/CMakeLists.txt @@ -1,4 +1,4 @@ -find_package(Qt6 REQUIRED COMPONENTS Widgets Quick ShaderTools) +find_package(Qt6 REQUIRED COMPONENTS Widgets Quick ShaderTools Gui) find_package(OpenAL REQUIRED) add_library(engine SHARED diff --git a/Engine/Engine.cpp b/Engine/Engine.cpp index b00f8966..c2a62810 100644 --- a/Engine/Engine.cpp +++ b/Engine/Engine.cpp @@ -77,6 +77,7 @@ class Engine::EnginePrivate QUrl startupScript; QUrl mainQML; bool running = false; + bool testMode = false; }; Engine::Engine(int &argc, char **argv) : QApplication(argc, argv), d_osr_ptr(new EnginePrivate()) @@ -95,6 +96,14 @@ Engine::Engine(int &argc, char **argv) : QApplication(argc, argv), d_osr_ptr(new d->qmlEngine->globalObject().setProperty("Engine", d->qmlEngine->newQObject(this)); d->qmlEngine->setObjectOwnership(this, QQmlEngine::CppOwnership); + d->qmlEngine->rootContext()->setContextProperty("isTestMode", false); + + d->testMode = arguments().contains("--test-mode"); + + if (d->testMode) + { + d->qmlEngine->rootContext()->setContextProperty("isTestMode", true); + } } Engine::~Engine() @@ -146,7 +155,13 @@ int Engine::run() auto scriptExec = [this] { Q_D(Engine); - if (!d->startupScript.isEmpty()) + + if (d->testMode) + { + execScript(QUrl("res:/opensrTestMode.js")); + return; + } + if (!d->startupScript.isEmpty() && !d->testMode) { execScript(d->startupScript); } @@ -178,7 +193,8 @@ void Engine::showQMLComponent(const QString &url) for (auto root : d->qmlEngine->rootObjects()) { - QMetaObject::invokeMethod(root, "destroyAndChangeScreen", Q_ARG(QVariant, QUrl(url)), Q_ARG(QVariant, QVariantMap())); + QMetaObject::invokeMethod(root, "destroyAndChangeScreen", Q_ARG(QVariant, QUrl(url)), + Q_ARG(QVariant, QVariantMap())); } } diff --git a/Engine/GAIAnimatedImage.cpp b/Engine/GAIAnimatedImage.cpp index b80b0a70..72d32ae6 100644 --- a/Engine/GAIAnimatedImage.cpp +++ b/Engine/GAIAnimatedImage.cpp @@ -58,7 +58,7 @@ class GAIAnimatedImage::GAIAnimatedImagePrivate QList> m_gaiOffsets; QTimer m_timer; - std::vector> m_textures; + std::vector> m_textures; long long m_currentFile = 0; bool m_fileChanged = false; bool m_playing = true; @@ -168,7 +168,7 @@ QSGNode *GAIAnimatedImage::updatePaintNode(QSGNode *oldNode, QQuickItem::UpdateP if (d->m_fileChanged) { - material->setTexture(d->m_textures[d->m_currentFile].get()); + material->setTexture(d->m_textures[d->m_currentFile]); node->markDirty(QSGNode::DirtyMaterial); d->m_fileChanged = false; } diff --git a/Engine/GAIMaterial.cpp b/Engine/GAIMaterial.cpp index f4fc281b..d288bd68 100644 --- a/Engine/GAIMaterial.cpp +++ b/Engine/GAIMaterial.cpp @@ -1,5 +1,6 @@ #include "GAIMaterial.h" #include "GAIShader.h" +#include namespace OpenSR { @@ -21,10 +22,10 @@ QSGMaterialShader *GAIMaterial::createShader(QSGRendererInterface::RenderMode) c GAITexture *GAIMaterial::texture() const { - return m_texture; + return m_texture.get(); } -void GAIMaterial::setTexture(GAITexture *texture) +void GAIMaterial::setTexture(std::shared_ptr texture) { m_texture = texture; } diff --git a/Engine/GAIMaterial.h b/Engine/GAIMaterial.h index b17b4c1a..e47d26d1 100644 --- a/Engine/GAIMaterial.h +++ b/Engine/GAIMaterial.h @@ -14,9 +14,9 @@ class GAIMaterial : public QSGMaterial QSGMaterialType *type() const override; QSGMaterialShader *createShader(QSGRendererInterface::RenderMode) const override; GAITexture *texture() const; - void setTexture(GAITexture *texture); + void setTexture(std::shared_ptr texture); private: - GAITexture *m_texture; + std::shared_ptr m_texture; }; } // namespace OpenSR \ No newline at end of file diff --git a/Engine/ResourceManager_p.h b/Engine/ResourceManager_p.h index 5c589677..d4acfab6 100644 --- a/Engine/ResourceManager_p.h +++ b/Engine/ResourceManager_p.h @@ -22,6 +22,7 @@ #include "OpenSR/ResourceManager.h" #include +#include #include class QFile; diff --git a/Engine/SoundManager.cpp b/Engine/SoundManager.cpp index 12c1b0d9..4cd14cee 100644 --- a/Engine/SoundManager.cpp +++ b/Engine/SoundManager.cpp @@ -249,7 +249,14 @@ MusicDecoder *SoundManager::getMusicDecoder(const QUrl &url, QObject *parent) } return new VorbisMusicDecoder(dev, parent); } - qWarning() << "Unsupported music format: " << QFileInfo(path).suffix(); + if (url != QUrl("")) + { + qWarning() << "Unsupported music format: " << QFileInfo(path).suffix(); + } + else + { + qDebug() << "Path url is empty: " << QFileInfo(path).suffix(); + } return 0; } diff --git a/ImagePlugin/CMakeLists.txt b/ImagePlugin/CMakeLists.txt index 475f5cc6..e56065f0 100644 --- a/ImagePlugin/CMakeLists.txt +++ b/ImagePlugin/CMakeLists.txt @@ -1,3 +1,5 @@ +find_package(Qt6 REQUIRED COMPONENTS Gui) + add_library(QtOpenSRImagePlugin SHARED GAIAnimationIO.cpp GIImageIO.cpp diff --git a/Makefile.root b/Makefile.root deleted file mode 100644 index c39402b6..00000000 --- a/Makefile.root +++ /dev/null @@ -1,26 +0,0 @@ -ASSETS ?= $(shell realpath ../OpenSRData) -DATCONVERT=build/tools/DATTools/opensr-dat-convert -DATJSON=build/tools/DATTools/opensr-dat-json -.PHONY: data - -all: data other - -data: - (cd data && find $(ASSETS) -iname '*.pkg' -exec ln -sfv {} \;) - #(cd data && ln -sfv $(ASSETS)/CacheData.dat) - #(cd data && ln -sfv $(ASSETS)/Main.dat main.dat) - -datfiles: $(DATCONVERT) $(DATJSON) - $(DATCONVERT) hd $(ASSETS)/CacheData.dat data/CacheData.dat - $(DATCONVERT) d $(ASSETS)/Rus.dat data/rus.dat - $(DATCONVERT) d $(ASSETS)/Main.dat data/main.dat - LD_LIBRARY_PATH=build/Ranger $(DATJSON) d2j data/main.dat data/main.json - #LD_LIBRARY_PATH=build/Ranger $(DATJSON) d2j data/CacheData.dat data/CacheData.json - LD_LIBRARY_PATH=build/Ranger $(DATJSON) d2j data/rus.dat data/rus.json - -other: - # To start game from command line - ln -sf build/World/libworld.so - # To allow resource viewer open GAI images - (mkdir -p build/tools/ResourceViewer/imageformats && cd build/tools/ResourceViewer/imageformats && ln -sf ../../../ImagePlugin/libQtOpenSRImagePlugin.so) - diff --git a/QM/CMakeLists.txt b/QM/CMakeLists.txt index 01b84d71..49ee536e 100644 --- a/QM/CMakeLists.txt +++ b/QM/CMakeLists.txt @@ -1,3 +1,5 @@ +find_package(Qt6 REQUIRED COMPONENTS Core) + add_library(QM SHARED QM.cpp Parser.cpp diff --git a/QM/Parser.cpp b/QM/Parser.cpp index 81909043..996133cf 100644 --- a/QM/Parser.cpp +++ b/QM/Parser.cpp @@ -21,7 +21,6 @@ #include #include #include -#include namespace OpenSR { diff --git a/QML/Button.qml b/QML/Button.qml index 2138f34c..8c2645d4 100644 --- a/QML/Button.qml +++ b/QML/Button.qml @@ -2,13 +2,16 @@ import QtQuick 2.3 import OpenSR 1.0 Item { + id: button + property string text - + property bool testConfig: isTestMode + property color textColor: "black" property int textStyle: Text.Normal property color textStyleColor: "black" property font textFont - + property string normalImage: "res:/ORC/ButtonN.sci" property string hoveredImage: "res:/ORC/ButtonA.sci" property string downImage: "res:/ORC/ButtonD.sci" @@ -18,49 +21,85 @@ Item { property bool sounded: true signal clicked - implicitWidth: Math.max(bg.implicitWidth, (label.implicitWidth + 10)) - implicitHeight: Math.max(bg.implicitHeight, (label.implicitHeight + 10)) + property color testNormalColor: "lightgray" + property color testHoveredColor: "gray" + property color testDownColor: "darkgray" - BorderImage { - id: bg + implicitWidth: testConfig ? 100 : (bgLoader.item ? bgLoader.item.implicitWidth : label.implicitWidth + 10) + implicitHeight: testConfig ? 30 : (bgLoader.item ? bgLoader.item.implicitHeight : label.implicitHeight + 10) + + Loader { + id: bgLoader anchors.fill: parent - source: (hoverArea.pressed && downImage != "") ? - parent.downImage : - ((hoverArea.containsMouse && hoveredImage != "") ? - parent.hoveredImage : parent.normalImage) + sourceComponent: testConfig ? testBackground : realBackground + } + + Component { + id: realBackground + BorderImage { + anchors.fill: parent + source: (hoverArea.pressed && button.downImage != "") ? button.downImage : ((hoverArea.containsMouse && button.hoveredImage != "") ? button.hoveredImage : button.normalImage) + } } + + Component { + id: testBackground + Rectangle { + color: hoverArea.pressed ? button.testDownColor : hoverArea.containsMouse ? button.testHoveredColor : button.testNormalColor + border.color: "darkgray" + border.width: 1 + radius: 4 + } + } + Text { id: label anchors.centerIn: parent - text: parent.text + text: button.text horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter renderType: Text.NativeRendering - - color: parent.textColor - style: parent.textStyle - styleColor: parent.textStyleColor - font: parent.textFont + + color: button.textColor + style: button.textStyle + styleColor: button.textStyleColor + font: button.textFont } + Sound { id: cSnd - source: clickSound + source: !button.testConfig && button.sounded ? button.clickSound : "res:/" } Sound { id: eSnd - source: enterSound + source: !button.testConfig && button.sounded ? button.enterSound : "res:/" } Sound { id: lSnd - source: leaveSound + source: !button.testConfig && button.sounded ? button.leaveSound : "res:/" } + MouseArea { id: hoverArea anchors.fill: parent hoverEnabled: true - onClicked: { parent.clicked() } - onPressed: { if(sounded) cSnd.play() } - onEntered: { if(sounded) eSnd.play() } - onExited: { if(sounded) lSnd.play() } + onClicked: { + button.clicked(); + } + onPressed: { + if (!button.testConfig && button.sounded) { + cSnd.play(); + } + } + onEntered: { + if (!button.testConfig && button.sounded) { + eSnd.play(); + } + } + onExited: { + if (!button.testConfig && button.sounded) { + lSnd.play(); + } + } } -} +} diff --git a/QML/MainMenu.qml b/QML/MainMenu.qml index ea45ad3d..8ba6330f 100644 --- a/QML/MainMenu.qml +++ b/QML/MainMenu.qml @@ -4,71 +4,95 @@ import OpenSR 1.0 Item { id: menu anchors.fill: parent - + property bool testConfig: isTestMode + function updateBackgroundAnim() { - bgToAnim.to = - background.width + menu.width - bgFromAnim.from = - background.width + menu.width - bgAnim.restart() + bgToAnim.to = -background.width + menu.width; + bgFromAnim.from = -background.width + menu.width; + bgAnim.restart(); } Connections { function onHeightChanged() { - background.height = menu.height - updateBackgroundAnim() + background.height = menu.height; + updateBackgroundAnim(); } } Image { id: background - source: "res:/DATA/FormMain3/2bg.gi" + visible: !testConfig + source: testConfig ? "" : "res:/DATA/FormMain3/2bg.gi" fillMode: Image.PreserveAspectCrop SequentialAnimation on x { id: bgAnim loops: Animation.Infinite - PropertyAnimation { id: bgToAnim; duration: 50000; from: 0 } - PropertyAnimation { id: bgFromAnim; duration: 50000; to: 0 } + PropertyAnimation { + id: bgToAnim + duration: 50000 + from: 0 + } + PropertyAnimation { + id: bgFromAnim + duration: 50000 + to: 0 + } } onStatusChanged: { if (status == Image.Ready) - updateBackgroundAnim() + updateBackgroundAnim(); } } Image { - source: "res:/DATA/FormMain3/2Planet.gi" + visible: !testConfig + source: testConfig ? "" : "res:/DATA/FormMain3/2Planet.gi" anchors.left: parent.left anchors.bottom: parent.bottom } GAIAnimatedImage { id: grid - sources: ["res:/DATA/FormMain2/2AnimMain.gai"] + visible: !testConfig + sources: testConfig ? null : ["res:/DATA/FormMain2/2AnimMain.gai"] speed: 0.75 anchors.fill: parent - + onCurrentFrameChanged: { - if(currentFrame == 0) + if (currentFrame == 0) gridPauseAnim.restart(); } SequentialAnimation { id: gridPauseAnim - - PropertyAction { target: grid; property: "playing"; value: false } - PauseAnimation { duration: 10000 } - PropertyAction { target: grid; property: "playing"; value: true } + + PropertyAction { + target: grid + property: "playing" + value: false + } + PauseAnimation { + duration: 10000 + } + PropertyAction { + target: grid + property: "playing" + value: true + } } } GAIAnimatedImage { id: animLine - sources: ["res:/DATA/FormMain2/2AnimLine.gai"] + visible: !testConfig + sources: testConfig ? null : ["res:/DATA/FormMain2/2AnimLine.gai"] speed: 0.5 width: parent.width anchors.right: parent.right anchors.top: parent.top } Image { - source: "res:/DATA/FormMain3/2caption.gi" + visible: !testConfig + source: testConfig ? "" : "res:/DATA/FormMain3/2caption.gi" anchors.top: parent.top anchors.horizontalCenter: animLine.right - anchors.topMargin: 20; + anchors.topMargin: 20 anchors.horizontalCenterOffset: -310 * animLine.width / animLine.implicitWidth } Item { @@ -77,49 +101,55 @@ Item { Button { id: newButton - normalImage: "res:/DATA/FormMain2/2ButNewN.gi" - hoveredImage: "res:/DATA/FormMain2/2ButNewA.gi" - downImage: "res:/DATA/FormMain2/2ButNewD.gi" + text: testConfig ? "New Game" : "" + normalImage: testConfig ? "" : "res:/DATA/FormMain2/2ButNewN.gi" + hoveredImage: testConfig ? "" : "res:/DATA/FormMain2/2ButNewA.gi" + downImage: testConfig ? "" : "res:/DATA/FormMain2/2ButNewD.gi" onClicked: newGame() } Button { id: loadButton - normalImage: "res:/DATA/FormMain2/2ButLoadN.gi" - hoveredImage: "res:/DATA/FormMain2/2ButLoadA.gi" - downImage: "res:/DATA/FormMain2/2ButLoadD.gi" + text: testConfig ? "Load" : "" + normalImage: testConfig ? "" : "res:/DATA/FormMain2/2ButLoadN.gi" + hoveredImage: testConfig ? "" : "res:/DATA/FormMain2/2ButLoadA.gi" + downImage: testConfig ? "" : "res:/DATA/FormMain2/2ButLoadD.gi" anchors.top: newButton.bottom anchors.topMargin: 10 onClicked: loadGame() } Button { id: settingsButton - normalImage: "res:/DATA/FormMain2/2ButSettingsN.gi" - hoveredImage: "res:/DATA/FormMain2/2ButSettingsA.gi" - downImage: "res:/DATA/FormMain2/2ButSettingsD.gi" + text: testConfig ? "Settings" : "" + normalImage: testConfig ? "" : "res:/DATA/FormMain2/2ButSettingsN.gi" + hoveredImage: testConfig ? "" : "res:/DATA/FormMain2/2ButSettingsA.gi" + downImage: testConfig ? "" : "res:/DATA/FormMain2/2ButSettingsD.gi" anchors.top: loadButton.bottom anchors.topMargin: 10 } Button { id: recordsButton - normalImage: "res:/DATA/FormMain2/2ButRecordsN.gi" - hoveredImage: "res:/DATA/FormMain2/2ButRecordsA.gi" - downImage: "res:/DATA/FormMain2/2ButRecordsD.gi" + text: testConfig ? "Recoreds" : "" + normalImage: testConfig ? "" : "res:/DATA/FormMain2/2ButRecordsN.gi" + hoveredImage: testConfig ? "" : "res:/DATA/FormMain2/2ButRecordsA.gi" + downImage: testConfig ? "" : "res:/DATA/FormMain2/2ButRecordsD.gi" anchors.top: settingsButton.bottom anchors.topMargin: 10 } Button { id: aboutButton - normalImage: "res:/DATA/FormMain2/2ButAboutN.gi" - hoveredImage: "res:/DATA/FormMain2/2ButAboutA.gi" - downImage: "res:/DATA/FormMain2/2ButAboutD.gi" + text: testConfig ? "About" : "" + normalImage: testConfig ? "" : "res:/DATA/FormMain2/2ButAboutN.gi" + hoveredImage: testConfig ? "" : "res:/DATA/FormMain2/2ButAboutA.gi" + downImage: testConfig ? "" : "res:/DATA/FormMain2/2ButAboutD.gi" anchors.top: recordsButton.bottom anchors.topMargin: 10 } Button { id: exitButton - normalImage: "res:/DATA/FormMain2/2ButExitN.gi" - hoveredImage: "res:/DATA/FormMain2/2ButExitA.gi" - downImage: "res:/DATA/FormMain2/2ButExitD.gi" + text: testConfig ? "Exit" : "" + normalImage: testConfig ? "" : "res:/DATA/FormMain2/2ButExitN.gi" + hoveredImage: testConfig ? "" : "res:/DATA/FormMain2/2ButExitA.gi" + downImage: testConfig ? "" : "res:/DATA/FormMain2/2ButExitD.gi" anchors.top: aboutButton.bottom anchors.topMargin: 10 onClicked: Qt.quit() @@ -129,24 +159,25 @@ Item { anchors.right: parent.right anchors.rightMargin: 100 } + GAIAnimatedImage { id: animation - sources: ["res:/DATA/FormMain3/2Ship1.gai", - "res:/DATA/FormMain3/2Ship2.gai", - "res:/DATA/FormMain3/2Ship3.gai"] + visible: !testConfig + sources: testConfig ? null : ["res:/DATA/FormMain3/2Ship1.gai", "res:/DATA/FormMain3/2Ship2.gai", "res:/DATA/FormMain3/2Ship3.gai"] speed: 1.5 anchors.left: parent.left anchors.bottom: parent.bottom } + Button { id: questButton - normalImage: "res:/DATA/FormLoadRobot/2LoadQuestN.gi" - hoveredImage: "res:/DATA/FormLoadRobot/2LoadQuestA.gi" - downImage: "res:/DATA/FormLoadRobot/2LoadQuestD.gi" + normalImage: testConfig ? "" : "res:/DATA/FormLoadRobot/2LoadQuestN.gi" + hoveredImage: testConfig ? "" : "res:/DATA/FormLoadRobot/2LoadQuestA.gi" + downImage: testConfig ? "" : "res:/DATA/FormLoadRobot/2LoadQuestD.gi" Text { id: questButtonText //% "Text quests" - text: qsTrId("FormMain.LQuest") + text: testConfig ? "" : qsTrId("FormMain.LQuest") color: "#FFC710" anchors.top: questButton.bottom anchors.horizontalCenter: questButton.horizontalCenter @@ -156,38 +187,52 @@ Item { anchors.bottomMargin: questButtonText.height + 10 anchors.leftMargin: 200 onClicked: { - gameScreen.createObjectFromURL("qrc:/OpenSR/QuestSelectionMenu.qml", menu, "questSelectionRequest") + gameScreen.createObjectFromURL("qrc:/OpenSR/QuestSelectionMenu.qml", menu, "questSelectionRequest"); } } - + Music { id: music - source: "res:/Music/SPECIAL/SpaceIsCalling.dat" + source: testConfig ? "" : "res:/Music/SPECIAL/SpaceIsCalling.dat" Component.onCompleted: { - play(); + if (!testConfig) { + music.play(); + } } } - + function startQuest(id) { - destroyAndChangeScreen("qrc:/OpenSR/QuestPlayer.qml", {"questID": id}); + destroyAndChangeScreen("qrc:/OpenSR/QuestPlayer.qml", { + "questID": id + }); } function newGame() { - World.generateWorld("res:/World/DefaultWorldGen.js"); - destroyAndChangeScreen("qrc:/OpenSR/SpaceView.qml", {"system": World.context.currentSystem}); + if (testConfig) { + World.generateWorld("res:/World/TestWorldGen.js"); + } else { + World.generateWorld("res:/World/DefaultWorldGen.js"); + } + + menu.destroy(); + changeScreen("qrc:/OpenSR/SpaceView.qml", { + "system": World.context.currentSystem + }); } function loadGame() { World.loadWorld("/tmp/test.osr"); - destroyAndChangeScreen("qrc:/OpenSR/SpaceView.qml", {"system": World.context.currentSystem}); + menu.destroy(); + changeScreen("qrc:/OpenSR/SpaceView.qml", { + "system": World.context.currentSystem + }); } - function componentObjectCreated(object, id) - { - if(id === "questSelectionRequest"){ - object.anchors.horizontalCenter = menu.horizontalCenter - object.anchors.verticalCenter = menu.verticalCenter - object.questSelected.connect(startQuest) + function componentObjectCreated(object, id) { + if (id === "questSelectionRequest") { + object.anchors.horizontalCenter = menu.horizontalCenter; + object.anchors.verticalCenter = menu.verticalCenter; + object.questSelected.connect(startQuest); } } } diff --git a/QML/ObjectConfig.js b/QML/ObjectConfig.js new file mode 100644 index 00000000..7aff3e40 --- /dev/null +++ b/QML/ObjectConfig.js @@ -0,0 +1,74 @@ +const ObjectTypes = { + PlanetarySystem: "OpenSR::World::PlanetarySystem", + Asteroid: "OpenSR::World::Asteroid", + DesertPlanet: "OpenSR::World::DesertPlanet", + InhabitedPlanet: "OpenSR::World::InhabitedPlanet", + SpaceStation: "OpenSR::World::SpaceStation", + Ship: "OpenSR::World::Ship" +}; + +const ObjectSettings = { + [ObjectTypes.PlanetarySystem]: { + positioning: false, + testSize: 100, + borderColor: "yellow", + getSource: function(obj) { return obj ? obj.style.star || "" : ""; } + }, + [ObjectTypes.Asteroid]: { + positioning: true, + testSize: 16, + borderColor: "green", + getSource: function(obj) { return obj ? obj.style.texture || "" : ""; } + }, + [ObjectTypes.DesertPlanet]: { + positioning: true, + testSize: 64, + borderColor: "brown", + getSource: function(obj) { return obj ? obj.style.texture || "" : ""; } + }, + [ObjectTypes.InhabitedPlanet]: { + positioning: true, + testSize: 64, + borderColor: "blue", + getSource: function(obj) { return obj ? obj.style.texture || "" : ""; } + }, + [ObjectTypes.SpaceStation]: { + positioning: true, + testSize: 64, + borderColor: "gray", + getSource: function(obj) { return obj ? obj.style.texture || "" : ""; } + }, + [ObjectTypes.Ship]: { + positioning: true, + testSize: 64, + borderColor: "orange", + getSource: function(obj) { return obj ? obj.style.texture || "" : ""; } + } +}; + +function getObjectSettings(object) { + if (!object) return null; + + const typeName = WorldManager.typeName(object.typeId); + return ObjectSettings[typeName] || null; +} + +function getPositioningForObject(obj) { + const settings = getObjectSettings(obj); + return settings ? settings.positioning : true; +} + +function getTestSizeForObject(obj) { + const settings = getObjectSettings(obj); + return settings ? settings.testSize : 64; +} + +function getBorderColorForObject(obj) { + const settings = getObjectSettings(obj); + return settings ? settings.borderColor : "green"; +} + +function getSourceForObject(obj) { + const settings = getObjectSettings(obj); + return settings && settings.getSource ? settings.getSource(obj) : ""; +} \ No newline at end of file diff --git a/QML/SpaceObjectItem.qml b/QML/SpaceObjectItem.qml index 30a3a11a..354e414d 100644 --- a/QML/SpaceObjectItem.qml +++ b/QML/SpaceObjectItem.qml @@ -4,6 +4,7 @@ import OpenSR.World 1.0 Item { id: self + property bool testConfig: isTestMode property WorldObject object property bool positioning: false property int mouseDelta: 0 @@ -17,24 +18,46 @@ Item { y: positioning && object ? object.position.y : 0 rotation: positioning && object ? radToDeg(shipAngle) : 0 + function getComponentForObject(obj) { + if (!obj) + return defaultComponent; + + const typeName = WorldManager.typeName(obj.typeId); + + const componentMap = { + "OpenSR::World::PlanetarySystem": defaultComponent, + "OpenSR::World::Asteroid": defaultComponent, + "OpenSR::World::DesertPlanet": planetComponent, + "OpenSR::World::InhabitedPlanet": planetComponent, + "OpenSR::World::SpaceStation": defaultComponent, + "OpenSR::World::Ship": shipComponent + }; + + return componentMap[typeName] || defaultComponent; + } + Loader { id: objectLoader anchors.centerIn: parent asynchronous: false onLoaded: { - if (WorldManager.typeName(object.typeId) === "OpenSR::World::PlanetarySystem") { - item.source = object.style.star; - } else if (WorldManager.typeName(object.typeId) === "OpenSR::World::Asteroid") { - item.source = object.style.texture; - } else if (WorldManager.typeName(object.typeId) === "OpenSR::World::DesertPlanet" || WorldManager.typeName(object.typeId) === "OpenSR::World::InhabitedPlanet") { + if (!object) + return; + + testBorder.border.color = ObjectConfig.getBorderColorForObject(object); + + if (testConfig) { + item.height = item.width = ObjectConfig.getTestSizeForObject(object); + } else if (item.hasOwnProperty("source")) { + item.source = ObjectConfig.getSourceForObject(object); + } + + if (item.hasOwnProperty("planet")) { item.planet = object; - } else if (WorldManager.typeName(object.typeId) === "OpenSR::World::Ship") { - item.source = object.style.texture; - item.height = item.width = object.style.width; + } + if (item.hasOwnProperty("ship")) { item.ship = object; - } else if (WorldManager.typeName(object.typeId) === "OpenSR::World::SpaceStation") { - item.source = object.style.texture; } } SpaceMouseArea { @@ -46,12 +69,21 @@ Item { } } + Rectangle { + id: testBorder + visible: testConfig && objectLoader.status === Loader.Ready + anchors.fill: objectLoader + color: "transparent" + border.color: "green" + border.width: 2 + } + Component { id: defaultComponent AnimatedImage { cache: false MouseArea { - id: item // ? + id: item anchors.fill: parent propagateComposedEvents: true } @@ -67,7 +99,7 @@ Item { MouseArea { propagateComposedEvents: true anchors.fill: parent - onDoubleClicked: (mouse) => { + onDoubleClicked: mouse => { mouse.accepted = false; if (!context.playerShip.isMoving && context.planetToEnter == null) { context.planetToEnter = planetItem.planet; @@ -96,7 +128,7 @@ Item { id: shipComponent AnimatedImage { - id: shipImage; + id: shipImage cache: false property Ship ship opacity: 1 @@ -108,7 +140,9 @@ Item { } } Behavior on scale { - NumberAnimation { duration: 2000 } + NumberAnimation { + duration: 2000 + } } Connections { target: ship @@ -124,7 +158,6 @@ Item { } } } - } onObjectChanged: { @@ -137,22 +170,8 @@ Item { return; } - if (WorldManager.typeName(object.typeId) === "OpenSR::World::PlanetarySystem") { - objectLoader.sourceComponent = defaultComponent; - positioning = false; - } else if (WorldManager.typeName(object.typeId) === "OpenSR::World::Asteroid") { - objectLoader.sourceComponent = defaultComponent; - positioning = true; - } else if (WorldManager.typeName(object.typeId) === "OpenSR::World::DesertPlanet" || WorldManager.typeName(object.typeId) === "OpenSR::World::InhabitedPlanet") { - objectLoader.sourceComponent = planetComponent; - positioning = true; - } else if (WorldManager.typeName(object.typeId) === "OpenSR::World::SpaceStation") { - objectLoader.sourceComponent = defaultComponent; - positioning = true; - } else if (WorldManager.typeName(object.typeId) === "OpenSR::World::Ship") { - objectLoader.sourceComponent = shipComponent; - positioning = true; - } + objectLoader.sourceComponent = getComponentForObject(object); + positioning = ObjectConfig.getPositioningForObject(object); } function mouseEntered() { @@ -168,4 +187,4 @@ Item { function radToDeg(rad) { return rad * (180 / Math.PI) + 90; } -} \ No newline at end of file +} diff --git a/QML/SpaceView.qml b/QML/SpaceView.qml index 2f1db041..fd2e4873 100644 --- a/QML/SpaceView.qml +++ b/QML/SpaceView.qml @@ -1,4 +1,5 @@ -import QtQuick 2.3 +import QtQuick +import QtQuick.Controls import OpenSR 1.0 import OpenSR.World 1.0 @@ -17,7 +18,63 @@ Item { property list clickables property var object + property bool testConfig: isTestMode + anchors.fill: parent + focus: true + + Popup { + id: escapeMenu + width: parent.width * 0.3 + height: parent.height * 0.4 + anchors.centerIn: parent + modal: true + focus: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: Rectangle { + color: "#202020" + border.color: "#505050" + radius: 5 + } + + Column { + anchors.fill: parent + anchors.margins: 10 + spacing: 10 + + Text { + text: "Menu" + color: "white" + font.bold: true + font.pixelSize: 20 + anchors.horizontalCenter: parent.horizontalCenter + } + + Button { + id: continueButton + text: "Continue" + width: parent.width + onClicked: escapeMenu.close() + } + + Button { + id: exitButton + text: "Exit to main menu" + width: parent.width + onClicked: exitToMenu() + } + } + } + + Keys.onEscapePressed: event => { + if (escapeMenu.opened) { + escapeMenu.close(); + } else { + escapeMenu.open(); + } + event.accepted = true; + } MouseArea { id: spaceMouseOverlay @@ -59,8 +116,9 @@ Item { y: parent.height / 2 Image { + visible: !testConfig id: bg - source: system ? system.style.background : "" + source: testConfig ? "" : system.style.background x: -width / 2 y: -height / 2 cache: false @@ -73,6 +131,7 @@ Item { } Item { + visible: !testConfig id: radarView anchors.right: parent.right anchors.top: parent.top @@ -81,7 +140,7 @@ Item { height: width Image { - source: "res:/DATA/PanelSpace2/1RadarA.gi" + source: testConfig ? "" : "res:/DATA/PanelSpace2/1RadarA.gi" anchors.fill: parent cache: true } @@ -89,9 +148,9 @@ Item { id: radarCenterButton anchors.bottom: parent.bottom anchors.right: parent.right - normalImage: "res:/DATA/PanelSpace2/1CenterN.gi" - hoveredImage: "res:/DATA/PanelSpace2/1CenterA.gi" - downImage: "res:/DATA/PanelSpace2/1CenterD.gi" + normalImage: testConfig ? "" : "res:/DATA/PanelSpace2/1CenterN.gi" + hoveredImage: testConfig ? "" : "res:/DATA/PanelSpace2/1CenterA.gi" + downImage: testConfig ? "" : "res:/DATA/PanelSpace2/1CenterD.gi" onClicked: console.log("Centering not implemented") } } @@ -101,9 +160,6 @@ Item { spaceNode.children[i].destroy(); } - if (!system) - return; - var component = Qt.createComponent("SpaceObjectItem.qml"); var o = component.createObject(spaceNode, { @@ -395,4 +451,9 @@ Item { hideTrajectory(context.playerShip); } } + + function exitToMenu() { + view.destroy(); + changeScreen("qrc:/OpenSR/MainMenu.qml"); + } } diff --git a/QML/TrajectoryItem.qml b/QML/TrajectoryItem.qml index 4f8eba76..c9481d45 100644 --- a/QML/TrajectoryItem.qml +++ b/QML/TrajectoryItem.qml @@ -4,86 +4,125 @@ import OpenSR.World 1.0 Item { property SpaceObject object - property SpaceObject oldObject property rect visibleRect property var curves + property bool testConfig: isTestMode Item { id: trajectory } + Component { + id: testCurveComponent + TexturedBezierCurve { + source: "" + minStep: 20 + } + } + + Component { + id: testStepComponent + Rectangle { + color: "transparent" + border.color: "green" + width: 12 + height: 12 + radius: 6 + x: -6 + y: -6 + } + } + + Component { + id: testFirstStepComponent + Rectangle { + color: "transparent" + border.color: "red" + width: 16 + height: 16 + radius: 8 + x: -8 + y: -8 + } + } + onObjectChanged: { if (oldObject) - oldObject.trajectoryChanged.disconnect(updateTrajectory) + oldObject.trajectoryChanged.disconnect(updateTrajectory); if (object) - object.trajectoryChanged.connect(updateTrajectory) + object.trajectoryChanged.connect(updateTrajectory); + + oldObject = object; - oldObject = object - if (object) - updateTrajectory() + updateTrajectory(); } - + function pointVisible(p) { - return !((p.x < visibleRect.x) || (p.y < visibleRect.y) || - (p.x > (visibleRect.x + visibleRect.width)) || - (p.y > (visibleRect.y + visibleRect.height))) + return !((p.x < visibleRect.x) || (p.y < visibleRect.y) || (p.x > (visibleRect.x + visibleRect.width)) || (p.y > (visibleRect.y + visibleRect.height))); } - + function updateTrajectory() { - for(var i in trajectory.children) { + for (var i in trajectory.children) { trajectory.children[i].destroy(); } - curves = object.trajectory - var beizerQML = "\ - import OpenSR 1.0;\ - TexturedBezierCurve {\ - source: \"dat:/Bm.PI.Path2\";\ - minStep: 20;\ - }" - - var visibleCurves = [] - var firstIncluded = false - for (i in curves) - { - if (!(pointVisible(curves[i].p0) || pointVisible(curves[i].p3))) - continue; - - if (i == 0) - firstIncluded = true; - visibleCurves.push(curves[i]) + + if (!object || !object.trajectory) { + return; + } + + curves = object.trajectory; + + var visibleCurves = []; + var firstIncluded = false; + for (i in curves) { + if (pointVisible(curves[i].p0) || pointVisible(curves[i].p3)) { + if (i == 0) + firstIncluded = true; + visibleCurves.push(curves[i]); + } + } + + if (testConfig) { + createTestTrajectory(visibleCurves, firstIncluded); + } else { + createRealTrajectory(visibleCurves, firstIncluded); + } + } + + function createTestTrajectory(visibleCurves, firstIncluded) { + for (var i in visibleCurves) { + testCurveComponent.createObject(trajectory, { + curve: visibleCurves[i] + }); + + var stepComponent = ((i == 0) && firstIncluded) ? testFirstStepComponent : testStepComponent; + + var stepObj = stepComponent.createObject(trajectory); + stepObj.x = visibleCurves[i].p3.x; + stepObj.y = visibleCurves[i].p3.y; } - - for (i in visibleCurves) - { - var o = Qt.createQmlObject(beizerQML, trajectory) + } + + function createRealTrajectory(visibleCurves, firstIncluded) { + var beizerQML = "import OpenSR 1.0; TexturedBezierCurve { source: \"dat:/Bm.PI.Path2\"; minStep: 20; }"; + var stepQML = "import QtQuick 2.3; Item { property alias source: image.source; Image { id: image; source: \"dat:/Bm.PI.Path3\"; x: -width/2; y: -height/2; } }"; + + for (var i in visibleCurves) { + var o = Qt.createQmlObject(beizerQML, trajectory); if ((i == 0) && firstIncluded) - o.source = "dat:/Bm.PI.Path1" - o.curve = visibleCurves[i] + o.source = "dat:/Bm.PI.Path1"; + o.curve = visibleCurves[i]; } - - var stepQML = "\ - import QtQuick 2.3;\ - Item {\ - property alias source: image.source;\ - Image {\ - id: image - source: \"dat:/Bm.PI.Path3\";\ - x: -width / 2;\ - y: -height / 2;\ - }\ - }"; - - - for (i in visibleCurves) - { - var o = Qt.createQmlObject(stepQML, trajectory) + + for (i in visibleCurves) { + var o = Qt.createQmlObject(stepQML, trajectory); if ((i == 0) && firstIncluded) - o.source = "dat:/Bm.PI.Path4" - o.x = visibleCurves[i].p3.x - o.y = visibleCurves[i].p3.y + o.source = "dat:/Bm.PI.Path4"; + o.x = visibleCurves[i].p3.x; + o.y = visibleCurves[i].p3.y; } } } diff --git a/QML/qml.qrc b/QML/qml.qrc index 8f6360b1..bf7d00c5 100644 --- a/QML/qml.qrc +++ b/QML/qml.qrc @@ -13,5 +13,6 @@ TrajectoryItem.qml PlanetItem.qml PlanetView.qml + ObjectConfig.js diff --git a/QML/qmldir b/QML/qmldir index 3b11a939..0ab60d65 100644 --- a/QML/qmldir +++ b/QML/qmldir @@ -6,3 +6,4 @@ MainMenu 1.0 MainMenu.qml SpaceObjectItem 1.0 SpaceObjectItem.qml PlanetItem 1.0 PlanetItem.qml DebugTooltip 1.0 DebugTooltip.qml +ObjectConfig 1.0 ObjectConfig.js \ No newline at end of file diff --git a/README.md b/README.md index c76a0fc5..a1a28d7f 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,322 @@ -# How to build +# OpenSR -## Prerequisites +OpenSR is an open-source project featuring a modern remake of the classic 2004 game *Space Rangers 2: Dominators*, along with a comprehensive toolkit for managing the original game's resources. The project is built on the Qt framework. -* cmake >= 3.0 -* Qt >= 5.9 -* OpenAL -* mpg123 +**Current Status**: The engine core is now functional, and a small technical demo is available for testing. This early version showcases the foundational systems while development continues toward full gameplay implementation. -## Building +## Features - mkdir build - cd build - cmake -DBUILD_ALL_TOOLS=Yes -DCMAKE_BUILD_TYPE=Debug ../ - make - -# How to run +- **Engine Foundation**: Core systems implemented and operational +- **Game Demo**: Early showcase of engine capabilities +- **Resource Management Tools**: Comprehensive utilities for handling original game assets +- **Comprehensive Testing**: GUI testing with Spix and unit tests with Qt Test +- **Code Coverage**: Integrated gcovr support for test coverage analysis -First of all, you'll need resources from original game. OpenSR now can only use -"Space Rangers 2: Reboot" ("Космические Рейнджеры 2: Доминаторы. Перезагрузка") -release of game. +## Requirements +### Build Dependencies +- **Qt 6** or newer (versions below 6 are not supported) +- **CMake** 3.10 or above +- **OpenAL** (Open Audio Library) -Copy following .pkg files (game resources) from install ISO to data/ folder: - - SR2/DATA/2captain.pkg - SR2/DATA/2gov.pkg - SR2/DATA/2Items.pkg - SR2/DATA/1main.pkg - SR2/DATA/2main.pkg - SR2/DATA/common.pkg - SR2/DATA/ShipFull.pkg - SR2/DATA/ShipSmall.pkg - SR2/DATA/Sound.pkg - SR2/DATA/Star.pkg +### Optional Dependencies (for testing) +- **[Spix](https://github.com/faaxm/spix)** - For GUI testing functionality +- **GTest framework** - Works in combination with Spix +- **gcovr** - For code coverage reporting -Copy music from install ISO to data/ folder: +## Platform Support - SR2/Music/ +### Current Build Status +- **Linux**: Fully supported, primary development platform +- **Windows**: Builds are possible but functionality is not thoroughly tested -*All the following commands assumed to be run from project root dir with `build` as build directory* +### Windows Build Considerations +While Windows builds are possible through CMake and Qt6, please note: +- **No Automatic Setup**: Currently there is no script for automatic resources preparation and symlink creation on Windows, so manual setup is required +- **Build Limitations**: Build functionality has not been extensively tested on Windows +- **Platform Issues**: Some platform-specific issues may occur +- **GUI Testing and Code Coverage**: GUI testing and gcovr may require additional setup and configuration -Next you have to decode several .dat files using previously built DATTools -(tools/DATTools): +For detailed instructions on manual resource setup, please refer to the [Detailed Manual Setup](#detailed-manual-setup) section. Windows users will need to use appropriate Windows utilities to perform the equivalent operations (such as using `mklink` for symlinks instead of `ln -s`). - ./build/tools/DATTools/opensr-dat-convert hd /SR2/CFG/CacheData.dat data/CacheData.dat - ./build/tools/DATTools/opensr-dat-convert d /SR2/CFG/Main.dat data/Main.dat - ./build/tools/DATTools/opensr-dat-convert d /SR2/CFG/Rus.dat data/Rus.dat +## Building from Source -(You can replace `SR2/CFG/Rus.dat` with `SR2/CFG/Eng.dat` for English version) +```bash +# Clone the repository +git clone https://github.com/OpenSRQt/OpenSR.git +cd OpenSR -Also, OpenSR requires that some libraries should be available in working directory: - - ln -s build/World/libworld.so - mkdir imageformats - ln -s ../build/ImagePlugin/libQtOpenSRImagePlugin.so imageformats/libQtOpenSRImagePlugin.so +# Create build directory +mkdir build && cd build -Finally, run OpenSR: - - ./build/Engine/opensr +# Configure with CMake (build all tools) +cmake -DBUILD_ALL_TOOLS=ON -DCMAKE_BUILD_TYPE=Debug ../ +# Build the project +cmake --build . +``` + +### CMake Build Options + +- `BUILD_RESOURCE_VIEWER`: Build Resource Viewer tool (default: OFF) +- `BUILD_PLANET_VIEWER`: Build Planet Viewer tool (default: OFF) +- `BUILD_QUEST_PLAYER`: Build Quest Player tool (default: OFF) +- `BUILD_DAT_TOOLS`: Build tools for DAT files (default: OFF) +- `BUILD_ALL_TOOLS`: Build all tools (overrides individual options, default: OFF) +- `BUILD_TESTS`: Build GUI and regular unit tests (default: OFF) +- `ENABLE_COVERAGE`: Enable code coverage (automatically enables BUILD_TESTS, default: OFF) + +## Usage + +### Game Demo + +The demo executable is located at: `OpenSR/build/Engine/opensr`. You have to run either without resources in test mode or prepare original game resource first. See next sections. + +**Important**: Currently, the executable must be launched from the OpenSR source directory: +```bash +./build/Engine/opensr +``` + +#### Running with Test Mode +OpenSR supports running without original game resources using test mode. + +Before running in test mode, set up required library symlinks: +```bash +./opensr_setup.sh -t demo +``` + +Run the game demo: +```bash +./build/Engine/opensr --test-mode +``` + +#### Preparing Original Game Resources + +**Important**: OpenSR has only been tested and verified with resources from **Space Rangers 2: Reboot** (Космические Рейнджеры 2: Доминаторы. Перезагрузка). Other versions of the game (including the original Space Rangers 2: Dominators release) may not work correctly or may cause unexpected issues. We strongly recommend using the "Reboot" version to ensure compatibility and proper functionality. + + +To use original Space Rangers 2 resources: + +1. Build with tools enabled: `cmake -DBUILD_ALL_TOOLS=ON ...` +2. Unpack your game image +3. Choose the appropriate setup method + +##### Quick Setup (Linux only) + +###### Step 1: Run the Setup Script + +```bash +./opensr_setup.sh -a /path/to/your/iso/SR2/ +``` + +For more information on setup script features run: +```bash +./opensr_setup.sh -h +``` + +To correctly run the demo following PKG files are expected in your DATA directory: +``` +2captain.pkg +2gov.pkg +2Items.pkg +1main.pkg +2main.pkg +common.pkg +ShipFull.pkg +ShipSmall.pkg +Sound.pkg +Star.pkg +``` + +**Default assets location**: `OpenSR/../OpenSRData` +**Default language**: `Rus` + +###### Step 2: Launch OpenSR +Run the game demo executable: + +```bash +./build/Engine/opensr +``` + +##### Detailed Manual Setup + +###### Step 1: Copy Resource Files +Copy the following .pkg files from your game installation to the `data/` folder: + +```bash +# Essential resource files +SR2/DATA/2captain.pkg +SR2/DATA/2gov.pkg +SR2/DATA/2Items.pkg +SR2/DATA/1main.pkg +SR2/DATA/2main.pkg +SR2/DATA/common.pkg +SR2/DATA/ShipFull.pkg +SR2/DATA/ShipSmall.pkg +SR2/DATA/Sound.pkg +SR2/DATA/Star.pkg + +# Music files +SR2/Music/ +``` + +###### Step 2: Decode DAT Files +Use the DATTools to decode essential configuration files: + +```bash +# Decode CacheData +./build/tools/DATTools/opensr-dat-convert hd /SR2/CFG/CacheData.dat data/CacheData.dat + +# Decode Main configuration +./build/tools/DATTools/opensr-dat-convert d /SR2/CFG/Main.dat data/Main.dat + +# Decode language file (for Russian version) +./build/tools/DATTools/opensr-dat-convert d /SR2/CFG/Rus.dat data/Rus.dat +# OR for English: +./build/tools/DATTools/opensr-dat-convert d /SR2/CFG/Eng.dat data/Eng.dat +``` + +###### Step 3: Set Up Library Symlinks +Create required symlinks for libraries: + +```bash +# World library +ln -s build/World/libworld.so + +# Image plugin +mkdir -p imageformats +ln -s ../build/ImagePlugin/libQtOpenSRImagePlugin.so imageformats/libQtOpenSRImagePlugin.so +``` + +###### Step 4: Launch OpenSR +Run the game executable: + +```bash +./build/Engine/opensr +``` + +##### File Structure After Setup +Your project directory should contain: + +``` +OpenSR/ +├── data/ +│ ├── *.pkg (game resource files) +│ ├── CacheData.dat (decoded) +│ ├── Main.dat (decoded) +│ ├── Rus.dat or Eng.dat (decoded) +│ └── Music/ (music files) +├── libworld.so -> build/World/libworld.so +└── imageformats/ + └── libQtOpenSRImagePlugin.so -> ../build/ImagePlugin/libQtOpenSRImagePlugin.so +``` + +### Toolkit + +#### ResourceViewer +GUI application for viewing and extracting resources from .pkg files. Supports game-specific formats (.gai, .gi, .hai, etc.). + +**Note**: If built separately, in project directory run: +```bash +./opensr_setup.sh -t tools +``` + +#### DATTools +Command-line utilities for managing DAT game files: + +**opensr-dat-convert** - Encrypt/decrypt and compress/decompress DAT files: +```bash +./build/tools/DATTools/opensr-dat-convert hd ../OpenSRData/CacheData.dat data/CacheData.dat +``` + +**opensr-dat-json** - Convert between DAT and JSON formats: +```bash +./build/tools/DATTools/opensr-dat-json d2j data/main.dat data/main.json +``` + +Run tools with `-h` option for detailed usage information. + +#### PlanetViewer +GUI application for interactive planet visualization with dynamic lighting. Must be launched from the project directory. + +#### QuestPlayer +GUI application for viewing and testing .qm files containing text adventure content. + +## Testing + +### Testing Framework +OpenSR employs a comprehensive testing strategy to ensure code quality and reliability: +- **GUI Testing**: Automated interface testing using the Spix framework +- **Unit Testing**: Core functionality testing with Qt Test framework + +### Building Tests + +#### GUI Tests Setup + +To enable GUI testing functionality, you need to install the Spix framework: + +**Important**: Before installing Spix, ensure you have installed its required dependency - AnyRPC. Please refer to the [Spix documentation](https://github.com/faaxm/spix) for detailed AnyRPC installation instructions. + +1. **Clone Spix repository** in your project directory: +```bash +git clone https://github.com/faaxm/spix +``` + +2. **Build and install Spix**: +```bash +cd spix +mkdir build && cd build +cmake -DSPIX_QT_MAJOR=6 -DSPIX_BUILD_EXAMPLES=OFF .. +cmake --build . +sudo cmake --install . +``` + +**Note**: GUI tests require Spix source code to be available in the `spix/` directory within the project. If Spix sources are not found, GUI test compilation will be automatically disabled. + +To enable testing capabilities, build with the `BUILD_TESTS` option: +```bash +# In build directory +cmake -DBUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Debug ../ +cmake --build . +``` + +### Running Tests +Execute all tests using ctest with verbose output: +```bash +cd build +ctest -VV +``` + +**Note**: GUI tests automatically run in `--test-mode` when executed via ctest. For standalone GUI testing, run the test executable directly from the project directory: +```bash +./build/tests/qml/opensr_ui_test +``` + +### Code Coverage Analysis +OpenSR integrates gcovr for detailed code coverage reporting: + +1. Build with coverage support enabled: +```bash +# In build directory +cmake -DENABLE_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug ../ +cmake --build . +``` + +2. Generate comprehensive coverage report: +```bash +# In project directory +cmake --build ./build --target global_coverage +``` + +The `global_coverage` target automatically verifies test availability and executes them via ctest before generating coverage data. + +Coverage results in JSON format are available at: +`build/coverage/coverage.json` + +## License + +This project is licensed under the GNU General Public License - see the [LICENSE](LICENSE) file for details. + +--- + +*This project is a fan-made recreation and is not affiliated with or endorsed by the original developers of Space Rangers 2.* \ No newline at end of file diff --git a/Ranger/CMakeLists.txt b/Ranger/CMakeLists.txt index 6db98b78..53e72c40 100644 --- a/Ranger/CMakeLists.txt +++ b/Ranger/CMakeLists.txt @@ -1,3 +1,5 @@ +find_package(Qt6 REQUIRED COMPONENTS Core Gui) + add_library(RangerQt SHARED GAILoader.cpp GILoader.cpp diff --git a/World/CMakeLists.txt b/World/CMakeLists.txt index dba5dfa4..b704e008 100644 --- a/World/CMakeLists.txt +++ b/World/CMakeLists.txt @@ -81,6 +81,9 @@ target_compile_definitions(world PRIVATE OPENSR_WORLD_BUILD) target_link_libraries(world PRIVATE engine +) + +target_link_libraries(world PUBLIC Qt6::Core Qt6::Gui Qt6::Widgets diff --git a/World/Ship.cpp b/World/Ship.cpp index f351b40c..79850f89 100644 --- a/World/Ship.cpp +++ b/World/Ship.cpp @@ -18,6 +18,7 @@ #include "Ship.h" +#include "ResourceManager.h" #include "WorldBindings.h" #include "Planet.h" @@ -80,6 +81,31 @@ void ShipStyle::setTexture(const QString &texture) setData(d); } +QDataStream &operator<<(QDataStream &stream, const ShipStyle &style) +{ + return stream << style.id(); +} + +QDataStream &operator>>(QDataStream &stream, ShipStyle &style) +{ + quint32 id{}; + stream >> id; + ResourceManager *m = ResourceManager::instance(); + Q_ASSERT(m != 0); + Resource::replaceData(style, m->getResource(id)); + return stream; +} + +QDataStream &operator<<(QDataStream &stream, const ShipStyle::Data &data) +{ + return stream << data.width << data.texture; +} + +QDataStream &operator>>(QDataStream &stream, ShipStyle::Data &data) +{ + return stream >> data.width >> data.texture; +} + bool operator==(const ShipStyle &one, const ShipStyle &another) { return one.texture() == another.texture(); @@ -194,16 +220,18 @@ void Ship::setIsMoving(bool isMoving) emit isMovingChanged(); } -void Ship::normalizeAnlge(float &deltaAngle) +void Ship::normalizeAngle(float &deltaAngle) { - while (deltaAngle > M_PI) + const float pi = static_cast(M_PI); + const float twoPi = static_cast(2 * M_PI); + while (deltaAngle > pi) { - deltaAngle -= 2 * M_PI; + deltaAngle -= twoPi; } - while (deltaAngle < -M_PI) + while (deltaAngle < -pi) { - deltaAngle += 2 * M_PI; + deltaAngle += twoPi; } } @@ -278,7 +306,7 @@ float Ship::calcAngle(const float dt, const float angle, const QPointF &pos, con { initTargetAngle(pos, dest); float deltaAngle = m_targetAngle - angle; - normalizeAnlge(deltaAngle); + normalizeAngle(deltaAngle); if (std::abs(deltaAngle) <= dt * m_angularSpeed || angle == m_targetAngle) { diff --git a/World/Ship.h b/World/Ship.h index a00c9336..05ea73ca 100644 --- a/World/Ship.h +++ b/World/Ship.h @@ -145,7 +145,7 @@ class OPENSR_WORLD_API Ship : public MannedObject float calcAngle(const float dt, const float angle, const QPointF &pos, const QPointF &dest); void updatePosition(const float dt = 0.0f); void updateAngle(const float dt = 0.0f); - void normalizeAnlge(float &deltaAngle); + static void normalizeAngle(float &deltaAngle); void initTargetAngle(const QPointF &pos, const QPointF &dest); void correctLinearSpeed(const QPointF &dest, const QPointF &pos); void resetSpeedParams(); @@ -163,6 +163,8 @@ class OPENSR_WORLD_API Ship : public MannedObject bool m_isNearPlanet = false; bool m_isMoving = false; bool m_actionsPlanned = false; + + friend class TestShip; }; } // namespace World } // namespace OpenSR diff --git a/data/World/DefaultWorldGen.js b/data/World/DefaultWorldGen.js index 7adbc26e..c2359deb 100644 --- a/data/World/DefaultWorldGen.js +++ b/data/World/DefaultWorldGen.js @@ -182,7 +182,6 @@ ship1.rank = World.ShipRank.Diplomat; ship1.style = shipStyleByAffiliation(ship1); ship1.angle = 0; -ship1.style.width = 64; context.playerShip = ship1; context.planetToEnter = null; context.currentSystem = system; diff --git a/data/World/TestWorldGen.js b/data/World/TestWorldGen.js new file mode 100644 index 00000000..e7fb5468 --- /dev/null +++ b/data/World/TestWorldGen.js @@ -0,0 +1,42 @@ +var context = World.context; + +var sector = World.Sector(context); + +var system = World.PlanetarySystem(sector); + +for(let i = 0; i < 20; i++) +{ + var asteroid = World.Asteroid(system); + asteroid.objectName = "Asteroid." + i; + asteroid.period = 15; + asteroid.semiAxis.x = 1500 + i * 10; + asteroid.semiAxis.y = 1000; + asteroid.angle = (i / 20) * 2 * 3.1415; +} + +var planet1 = World.DesertPlanet(system); +planet1.period = 15; +planet1.angle = 3.1415 / 4; +planet1.position = Qt.point(355, -222); + +var planet2 = World.InhabitedPlanet(system); +planet2.period = 15; +planet2.angle = 3.1415 / 4; +planet2.position = Qt.point(355, 222); + +var rangerCenter = World.SpaceStation(system); +rangerCenter.position = Qt.point(-400, -100); +rangerCenter.stationKind = World.StationKind.RangerCenter; + +var ship1 = World.Ship(context); + +ship1.position = Qt.point(-300, -300); +ship1.affiliation = World.ShipAffiliation.People; +ship1.rank = World.ShipRank.Diplomat; +ship1.angle = 0; + +context.playerShip = ship1; +context.planetToEnter = null; +context.currentSystem = system; + +World.saveWorld("/tmp/test.osr"); \ No newline at end of file diff --git a/data/opensrTestMode.js b/data/opensrTestMode.js new file mode 100644 index 00000000..d88b931f --- /dev/null +++ b/data/opensrTestMode.js @@ -0,0 +1,2 @@ +Engine.loadPlugin("world") +Engine.showQMLComponent("qrc:/OpenSR/MainMenu.qml") \ No newline at end of file diff --git a/opensr_setup.sh b/opensr_setup.sh new file mode 100755 index 00000000..2c388ce2 --- /dev/null +++ b/opensr_setup.sh @@ -0,0 +1,176 @@ +#!/bin/bash + +ASSETS="${ASSETS:-$(realpath "../SR2Image")}" +LNG="${LNG:-Rus}" +TARGET="${TARGET:-all}" +DATCONVERT="build/tools/DATTools/opensr-dat-convert" +DATJSON="build/tools/DATTools/opensr-dat-json" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +help_function() +{ + echo "" + echo "Usage: $0 [-a assets_path] [-l language] [-t target]" + echo -e "\t-a Path to SR2 assets directory (default: ../SR2Image)" + echo -e "\t-l Language: Rus or Eng (default: Rus)" + echo -e "\t-t Target: all, data, datfiles, tools, demo (default: all)" + echo "" + exit 1 +} + +# Parse command line options +while getopts "a:l:t:h" opt +do + case "$opt" in + a ) ASSETS="$OPTARG" ;; + l ) LNG="$OPTARG" ;; + t ) TARGET="$OPTARG" ;; + h ) help_function ;; + ? ) help_function ;; + esac +done + +print_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" + help_function +} + +verify_assets() { + if [ ! -d "$ASSETS" ]; then + print_error "Assets directory not found: $ASSETS" + fi +} + +verify_lang() { + if [ "$LNG" != "Rus" ] && [ "$LNG" != "Eng" ]; then + print_error "Unsupported language '$LNG'. Supported values: Rus, Eng" + fi +} + +check_dependencies() { + if [ ! -f "$DATCONVERT" ]; then + print_error "DATCONVERT not found: $DATCONVERT" + fi + + if [ ! -f "$DATJSON" ]; then + print_error "DATJSON not found: $DATJSON" + fi +} + +create_data_links() { + verify_assets + + print_info "Creating symlinks to .pkg files" + cd data && find "$ASSETS/DATA" -iname '*.pkg' -exec ln -sfv {} \; && cd .. || print_error "Failed to create pkg symlinks" + + print_info "Creating symlink to Music directory" + cd data && ln -sf "$ASSETS/Music/" . && cd .. || print_warning "Failed to create Music directory symlink" +} + +process_dat_files() { + verify_assets + verify_lang + + local lang_file="$LNG" + local lang_file_lower=$(echo "$LNG" | tr '[:upper:]' '[:lower:]') + local fallback="" + + if [ "$LNG" = "Rus" ]; then + fallback="Eng" + else + fallback="Rus" + fi + + # Check if main language file exists, use fallback if not + if [ ! -f "$ASSETS/CFG/$lang_file.dat" ]; then + print_warning "$lang_file.dat not found, trying fallback: $fallback.dat" + lang_file="$fallback" + lang_file_lower=$(echo "$fallback" | tr '[:upper:]' '[:lower:]') + + # Check if fallback file exists + if [ ! -f "$ASSETS/CFG/$lang_file.dat" ]; then + print_error "Neither $LNG.dat nor $fallback.dat found in $ASSETS/CFG/" + fi + fi + + print_info "Decoding DAT files" + "$DATCONVERT" hd "$ASSETS/CFG/CacheData.dat" "data/CacheData.dat" || print_error "Failed to decode CacheData.dat" + "$DATCONVERT" d "$ASSETS/CFG/$lang_file.dat" "data/$lang_file.dat" || print_error "Failed to decode $lang_file_lower.dat" + "$DATCONVERT" d "$ASSETS/CFG/Main.dat" "data/main.dat" || print_error "Failed to decode Main.dat" + + print_info "Converting DAT files to JSON" + export LD_LIBRARY_PATH="build/Ranger" + "$DATJSON" d2j "data/main.dat" "data/main.json" || print_error "Failed to convert main.dat to JSON" + "$DATJSON" d2j "data/CacheData.dat" "data/CacheData.json" || print_error "Failed to convert CacheData.dat to JSON" + "$DATJSON" d2j "data/$lang_file.dat" "data/$lang_file_lower.json" || print_error "Failed to convert $lang_file.dat to JSON" +} + +setup_demo() { + print_info "Setting up symlinks required for demo" + + # World library + ln -sf "build/World/libworld.so" || print_warning "Failed to create libworld.so symlink" + + # Image plugins for demo + mkdir -p "imageformats" && \ + cd "imageformats" && \ + ln -sf "../build/ImagePlugin/libQtOpenSRImagePlugin.so" . && \ + cd .. || print_warning "Failed to setup image formats for demo" +} + +setup_tools() { + # Image plugins for resource viewer + + print_info "Setting up symlinks required for resource viewer" + + mkdir -p "build/tools/ResourceViewer/imageformats" && \ + cd "build/tools/ResourceViewer/imageformats" && \ + ln -sf "../../../ImagePlugin/libQtOpenSRImagePlugin.so" . && \ + cd ../../../../ || print_warning "Failed to setup image formats for resource viewer" +} + +main() { + print_info "Starting setup process with ASSETS=$ASSETS, LNG=$LNG" + + check_dependencies + create_data_links + process_dat_files + setup_tools + setup_demo + + print_info "Setup completed successfully!" +} + +case "$TARGET" in + "data") + create_data_links + ;; + "datfiles") + check_dependencies + process_dat_files + ;; + "tools") + setup_tools + ;; + "demo") + setup_demo + ;; + "all") + main + ;; + *) + print_error "Unknown target '$TARGET'" + ;; +esac \ No newline at end of file diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 00000000..eac9c211 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,9 @@ +if(BUILD_TESTS) + add_subdirectory(World) + + if(EXISTS ${CMAKE_SOURCE_DIR}/spix) + add_subdirectory(qml) + else() + message(WARNING "Unable to build GUI tests without Spix in OpenSR source directory! Please, see readme file.") + endif() +endif() \ No newline at end of file diff --git a/tests/World/CMakeLists.txt b/tests/World/CMakeLists.txt new file mode 100644 index 00000000..61a1f41c --- /dev/null +++ b/tests/World/CMakeLists.txt @@ -0,0 +1,7 @@ +add_custom_target(world_tests COMMENT "Building all World tests") + +add_subdirectory(Ship) + +if (ENABLE_COVERAGE) + add_dependencies(run_all_tests world_tests) +endif() \ No newline at end of file diff --git a/tests/World/Ship/CMakeLists.txt b/tests/World/Ship/CMakeLists.txt new file mode 100644 index 00000000..162de0bc --- /dev/null +++ b/tests/World/Ship/CMakeLists.txt @@ -0,0 +1,20 @@ +find_package(Qt6 REQUIRED COMPONENTS Test Core) + +add_executable(ship_test + TestShip.h + TestShip.cpp +) + +target_include_directories(ship_test PRIVATE + ${CMAKE_SOURCE_DIR}/World +) + +target_link_libraries(ship_test PRIVATE + Qt6::Test + Qt6::Core + world +) + +add_test(NAME ship_test COMMAND ship_test WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) + +add_dependencies(world_tests ship_test) \ No newline at end of file diff --git a/tests/World/Ship/TestShip.cpp b/tests/World/Ship/TestShip.cpp new file mode 100644 index 00000000..7156e2c5 --- /dev/null +++ b/tests/World/Ship/TestShip.cpp @@ -0,0 +1,296 @@ +#include "TestShip.h" +#include "Ship.h" + +namespace OpenSR +{ +namespace World +{ +void TestShip::testNormalizeAngle_data() +{ + QTest::addColumn("angle"); + QTest::addColumn("expectedAngle"); + + const auto pi = static_cast(M_PI); + const auto twoPi = static_cast(2 * M_PI); + + QTest::newRow("angle within range") << 0.0f << 0.0f; + QTest::newRow("positive angle within range") << pi / 2 << pi / 2; + QTest::newRow("negative angle within range") << -pi / 2 << -pi / 2; + QTest::newRow("exactly pi") << pi << pi; + QTest::newRow("exactly -pi") << -pi << -pi; + QTest::newRow("angle > pi") << 3 * pi / 2 << -pi / 2; + QTest::newRow("angle > 2pi") << twoPi + pi / 4 << pi / 4; + QTest::newRow("angle much > pi") << 5 * pi << pi; + QTest::newRow("angle < -pi") << -3 * pi / 2 << pi / 2; + QTest::newRow("angle < -2pi") << -twoPi - pi / 4 << -pi / 4; + QTest::newRow("angle much < -pi") << -5 * pi << -pi; +} + +void TestShip::testNormalizeAngle() +{ + QFETCH(float, angle); + QFETCH(float, expectedAngle); + + auto actualAngle = angle; + Ship ship{}; + ship.normalizeAngle(actualAngle); + + QVERIFY2(qFuzzyCompare(actualAngle, expectedAngle), + qPrintable(QString("Actual: %1, Expected: %2").arg(actualAngle).arg(expectedAngle))); +} + +void TestShip::testInitTargetAngle_data() +{ + QTest::addColumn("position"); + QTest::addColumn("destination"); + QTest::addColumn("expectedAngle"); + + const float pi = static_cast(M_PI); + + QTest::newRow("east direction") << QPointF(0, 0) << QPointF(10, 0) << 0.0f; + QTest::newRow("north direction") << QPointF(0, 0) << QPointF(0, 10) << pi / 2; + QTest::newRow("west direction") << QPointF(0, 0) << QPointF(-10, 0) << pi; + QTest::newRow("south direction") << QPointF(0, 0) << QPointF(0, -10) << -pi / 2; + QTest::newRow("northeast direction") << QPointF(0, 0) << QPointF(10, 10) << pi / 4; + QTest::newRow("southwest direction") << QPointF(0, 0) << QPointF(-10, -10) << -3 * pi / 4; + QTest::newRow("non zero start position") << QPointF(5, 5) << QPointF(15, 5) << 0.0f; + QTest::newRow("small coordinates") << QPointF(0.1f, 0.1f) << QPointF(0.2f, 0.1f) << 0.0f; +} + +void TestShip::testInitTargetAngle() +{ + QFETCH(QPointF, position); + QFETCH(QPointF, destination); + QFETCH(float, expectedAngle); + + Ship ship{}; + ship.initTargetAngle(position, destination); + + const auto actualAngle = ship.m_targetAngle; + + QVERIFY2(qFuzzyCompare(actualAngle, expectedAngle), + qPrintable(QString("Actual angle: %1, Expected: %2").arg(actualAngle).arg(expectedAngle))); +} + +void TestShip::testCorrectLinearSpeed_data() +{ + QTest::addColumn("position"); + QTest::addColumn("destination"); + QTest::addColumn("initialAngle"); + QTest::addColumn("expectedSpeed"); + + const auto pi = static_cast(M_PI); + const auto normalSpeed = Ship::normalLinearSpeed; + + QTest::newRow("straight movement no correction") << QPointF(0, 0) << QPointF(100, 0) << 0.0f << normalSpeed; + QTest::newRow("sharp turn 90 deg requires correction") << QPointF(0, 0) << QPointF(10, 10) << pi / 2 << 0.03f; + QTest::newRow("medium turn 45 deg no correction") << QPointF(0, 0) << QPointF(100, 100) << pi / 4 << normalSpeed; + QTest::newRow("backward movement") << QPointF(0, 0) << QPointF(-100, 0) << pi << normalSpeed; + QTest::newRow("small distance correction") << QPointF(0, 0) << QPointF(1, 1) << 0.0f << 0.003f; +} + +void TestShip::testCorrectLinearSpeed() +{ + QFETCH(QPointF, position); + QFETCH(QPointF, destination); + QFETCH(float, initialAngle); + QFETCH(float, expectedSpeed); + + Ship ship{}; + ship.m_angle = initialAngle; + + ship.correctLinearSpeed(destination, position); + + const auto actualSpeed = ship.speed(); + + QVERIFY2(qFuzzyCompare(actualSpeed, expectedSpeed), qPrintable(QString("Speed correction failed\n" + "Actual: %1\n" + "Expected: %2\n" + "Initial angle: %3 rad (%4 degrees)") + .arg(actualSpeed) + .arg(expectedSpeed) + .arg(initialAngle) + .arg(qRadiansToDegrees(initialAngle)))); +} + +void TestShip::testCalcAngle_data() +{ + QTest::addColumn("dt_ms"); + QTest::addColumn("currentAngle"); + QTest::addColumn("position"); + QTest::addColumn("destination"); + QTest::addColumn("expectedAngle"); + + const auto pi = static_cast(M_PI); + const auto angularSpeedPerMs = Ship::normalAngularSpeed; + + const auto dt_60fps = 17.0f; + const auto dt_30fps = 33.0f; + + QTest::newRow("60fps already at target") + << dt_60fps << pi / 4.0f << QPointF(0.0f, 0.0f) << QPointF(10.0f, 10.0f) << pi / 4.0f; + QTest::newRow("60fps counter clockwise") + << dt_60fps << 0.0f << QPointF(0.0f, 0.0f) << QPointF(-10.0f, 0.1f) << 0.0f + dt_60fps * angularSpeedPerMs; + QTest::newRow("60fps clockwise") << dt_60fps << pi << QPointF(0.0f, 0.0f) << QPointF(10.0f, 0.1f) + << pi - dt_60fps * angularSpeedPerMs; + QTest::newRow("30fps medium rotation") + << dt_30fps << 0.0f << QPointF(0.0f, 0.0f) << QPointF(-10.0f, 10.0f) << 0.0f + dt_30fps * angularSpeedPerMs; + QTest::newRow("60fps small rotation") + << dt_60fps << pi - 0.001f << QPointF(0.0f, 0.0f) << QPointF(-10.0f, 0.0f) << pi; + QTest::newRow("60fps reach target in one frame") + << dt_60fps << 0.0f << QPointF(0.0f, 0.0f) << QPointF(0.0f, 0.1f) << dt_60fps * angularSpeedPerMs; +} + +void TestShip::testCalcAngle() +{ + QFETCH(float, dt_ms); + QFETCH(float, currentAngle); + QFETCH(QPointF, position); + QFETCH(QPointF, destination); + QFETCH(float, expectedAngle); + + Ship ship{}; + ship.setAngle(currentAngle); + + const auto resultAngle = ship.calcAngle(dt_ms, currentAngle, position, destination); + + QVERIFY2(qFuzzyCompare(resultAngle, expectedAngle), qPrintable(QString("Angle calculation failed\n" + "Time step: %1 s\n" + "Start angle: %2 rad (%3 degrees)\n" + "Result angle: %4 rad (%5 degrees)\n" + "Expected angle: %6 rad (%7 degrees)") + .arg(dt_ms) + .arg(currentAngle) + .arg(qRadiansToDegrees(currentAngle)) + .arg(resultAngle) + .arg(qRadiansToDegrees(resultAngle)) + .arg(expectedAngle) + .arg(qRadiansToDegrees(expectedAngle)))); +} + +void TestShip::testCalcPosition_data() +{ + QTest::addColumn("dt_ms"); + QTest::addColumn("angle"); + QTest::addColumn("position"); + QTest::addColumn("destination"); + QTest::addColumn("expectedPosition"); + + const auto pi = static_cast(M_PI); + const auto linearSpeedPerMs = Ship::normalLinearSpeed; + + const auto dt_60fps = 17.0f; + const auto dt_30fps = 33.0f; + + QTest::newRow("already at destination") + << dt_60fps << 0.0f << QPointF(10.0f, 10.0f) << QPointF(10.0f, 10.0f) << QPointF(10.0f, 10.0f); + QTest::newRow("60fps move east") << dt_60fps << 0.0f << QPointF(0.0f, 0.0f) << QPointF(100.0f, 0.0f) + << QPointF(dt_60fps * linearSpeedPerMs, 0.0f); + QTest::newRow("30fps move north") << dt_30fps << pi / 2 << QPointF(0, 0) << QPointF(0, 100) + << QPointF(0.0f, dt_30fps * linearSpeedPerMs); + QTest::newRow("60fps move northeast") + << dt_60fps << pi / 4 << QPointF(0.0f, 0.0f) << QPointF(100.0f, 100.0f) + << QPointF(dt_60fps * linearSpeedPerMs * std::cos(pi / 4), dt_60fps * linearSpeedPerMs * std::sin(pi / 4)); + QTest::newRow("60fps move southwest") + << dt_60fps << -pi / 4 << QPointF(0.0f, 0.0f) << QPointF(-100.0f, -100.0f) + << QPointF(dt_60fps * linearSpeedPerMs * std::cos(-pi / 4), dt_60fps * linearSpeedPerMs * std::sin(-pi / 4)); + QTest::newRow("reach destination exactly") + << (10.0f / linearSpeedPerMs) << 0.0f << QPointF(0.0f, 0.0f) << QPointF(10.0f, 0.0f) << QPointF(10.0f, 0.0f); +} + +void TestShip::testCalcPosition() +{ + QFETCH(float, dt_ms); + QFETCH(float, angle); + QFETCH(QPointF, position); + QFETCH(QPointF, destination); + QFETCH(QPointF, expectedPosition); + + Ship ship{}; + const auto result = ship.calcPosition(dt_ms, angle, position, destination); + + auto fuzzyEqual = [](float a, float b) { return qFuzzyCompare(a, b) || qAbs(a - b) < 1e-6f; }; + + const bool match = fuzzyEqual(static_cast(result.x()), static_cast(expectedPosition.x())) && + fuzzyEqual(static_cast(result.y()), static_cast(expectedPosition.y())); + + QVERIFY2(match, qPrintable(QString("Position mismatch" + "Actual: (%1, %2)\n" + "Expected: (%3, %4)\n" + "dt: %5 ms, angle: %6 rad") + .arg(result.x()) + .arg(result.y()) + .arg(expectedPosition.x()) + .arg(expectedPosition.y()) + .arg(dt_ms) + .arg(angle))); +} + +void TestShip::testUpdatePositionMoving_data() +{ + QTest::addColumn("dt_ms"); + QTest::addColumn("expectedPosition"); + QTest::addColumn("shouldArrive"); + + const auto linearSpeedPerMs = Ship::normalLinearSpeed; + const auto dt_60fps = 17.0f; + const auto dt_30fps = 33.0f; + + QTest::newRow("60fps movement") << dt_60fps << QPointF(dt_60fps * linearSpeedPerMs, 0.0f) << false; + QTest::newRow("30fps movement") << dt_30fps << QPointF(33.0f * linearSpeedPerMs, 0.0f) << false; + QTest::newRow("exact arrival") << static_cast(100 / linearSpeedPerMs) << QPointF(100.0f, 0.0f) << true; + QTest::newRow("near arrival") << 330.0f << QPointF(99.0f, 0.0f) << false; +} + +void TestShip::testUpdatePositionMoving() +{ + QFETCH(float, dt_ms); + QFETCH(QPointF, expectedPosition); + QFETCH(bool, shouldArrive); + + Ship ship{}; + ship.setPosition(QPointF(0, 0)); + ship.setDestination(QPointF(100, 0)); + ship.setAngle(0.0f); + ship.setIsMoving(true); + ship.updatePosition(dt_ms); + + const bool match = + qFuzzyCompare(static_cast(ship.position().x()), static_cast(expectedPosition.x())) && + qFuzzyCompare(static_cast(ship.position().y()), static_cast(expectedPosition.y())); + + QVERIFY2(match, qPrintable(QString("Position mismatch.\n" + "Actual: (%1, %2)\n" + "Expected: (%3, %4)\n" + "dt: %5 ms") + .arg(ship.position().x()) + .arg(ship.position().y()) + .arg(expectedPosition.x()) + .arg(expectedPosition.y()) + .arg(dt_ms))); + + QCOMPARE(ship.isMoving(), !shouldArrive); +} + +void TestShip::testUpdatePositionSignals() +{ + Ship ship{}; + ship.setPosition(QPointF(0.0f, 0.0f)); + ship.setDestination(QPointF(100.0f, 0.0f)); + ship.setAngle(0.0f); + ship.setIsMoving(true); + + QSignalSpy arrivedSpy(&ship, &Ship::shipArrived); + + ship.updatePosition(16.0f); + QCOMPARE(arrivedSpy.count(), 0); + + ship.setPosition(QPointF(99.9f, 0)); + ship.updatePosition(1.0f); + QCOMPARE(arrivedSpy.count(), 1); +} + +} // namespace World +} // namespace OpenSR + +QTEST_APPLESS_MAIN(OpenSR::World::TestShip) \ No newline at end of file diff --git a/tests/World/Ship/TestShip.h b/tests/World/Ship/TestShip.h new file mode 100644 index 00000000..b0299407 --- /dev/null +++ b/tests/World/Ship/TestShip.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +namespace OpenSR +{ +namespace World +{ + +class TestShip : public QObject +{ + Q_OBJECT + +private slots: + void testNormalizeAngle_data(); + void testNormalizeAngle(); + void testInitTargetAngle_data(); + void testInitTargetAngle(); + void testCorrectLinearSpeed_data(); + void testCorrectLinearSpeed(); + void testCalcAngle_data(); + void testCalcAngle(); + void testCalcPosition_data(); + void testCalcPosition(); + void testUpdatePositionMoving_data(); + void testUpdatePositionMoving(); + void testUpdatePositionSignals(); +}; +} // namespace World +} // namespace OpenSR \ No newline at end of file diff --git a/tests/qml/CMakeLists.txt b/tests/qml/CMakeLists.txt new file mode 100644 index 00000000..088f9b66 --- /dev/null +++ b/tests/qml/CMakeLists.txt @@ -0,0 +1,22 @@ +LIST(APPEND CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR}/spix/cmake/modules) + +find_package(Qt6 COMPONENTS Core Quick Widgets REQUIRED) +find_package(GTest REQUIRED) +find_package(Spix COMPONENTS Core QtQuick REQUIRED) + +add_executable(opensr_ui_test main.cpp ErrorCollector.cpp ErrorCollector.h SpixOSRTest.cpp SpixOSRTest.h ${CMAKE_SOURCE_DIR}/QML/qml.qrc) + +target_link_libraries(opensr_ui_test PRIVATE + Qt6::Core + Qt6::Quick + Qt6::Widgets + GTest::GTest + Spix::QtQuick + engine +) + +add_test(NAME opensr_ui_test COMMAND opensr_ui_test --test-mode WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) + +if (ENABLE_COVERAGE) + add_dependencies(run_all_tests opensr_ui_test) +endif() diff --git a/tests/qml/ErrorCollector.cpp b/tests/qml/ErrorCollector.cpp new file mode 100644 index 00000000..3dae3e5b --- /dev/null +++ b/tests/qml/ErrorCollector.cpp @@ -0,0 +1,46 @@ +#include "ErrorCollector.h" + +namespace GUITest +{ +ErrorCollector &ErrorCollector::instance() +{ + static ErrorCollector collector; + return collector; +} + +void ErrorCollector::install() +{ + m_errors.clear(); + m_oldHandler = qInstallMessageHandler(ErrorCollector::messageHandler); +} + +void ErrorCollector::uninstall() +{ + qInstallMessageHandler(m_oldHandler); +} + +const QVector &ErrorCollector::errors() const +{ + return m_errors; +} +void ErrorCollector::clear() +{ + m_errors.clear(); +} + +void ErrorCollector::messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg) +{ + if (type == QtWarningMsg || type == QtCriticalMsg || type == QtFatalMsg) + { + QString formattedMsg = + QString("[%1] %2:%3 - %4\n").arg(context.category).arg(context.file).arg(context.line).arg(msg); + + instance().m_errors.append(formattedMsg); + } + + if (instance().m_oldHandler) + { + instance().m_oldHandler(type, context, msg); + } +} +} // namespace GUITest \ No newline at end of file diff --git a/tests/qml/ErrorCollector.h b/tests/qml/ErrorCollector.h new file mode 100644 index 00000000..78a1efdd --- /dev/null +++ b/tests/qml/ErrorCollector.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +namespace GUITest +{ +class ErrorCollector +{ +public: + static ErrorCollector &instance(); + void install(); + void uninstall(); + const QVector &errors() const; + void clear(); + static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg); + +private: + ErrorCollector() = default; + QVector m_errors; + QtMessageHandler m_oldHandler = nullptr; +}; +} // namespace GUITest \ No newline at end of file diff --git a/tests/qml/SpixOSRTest.cpp b/tests/qml/SpixOSRTest.cpp new file mode 100644 index 00000000..97751815 --- /dev/null +++ b/tests/qml/SpixOSRTest.cpp @@ -0,0 +1,81 @@ +#include "SpixOSRTest.h" +#include "ErrorCollector.h" +#include +#include +#include +#include +#include + +namespace GUITest +{ +SpixOSRTest::SpixOSRTest() +{ + ErrorCollector::instance().install(); +} + +SpixOSRTest::~SpixOSRTest() +{ + ErrorCollector::instance().uninstall(); +} + +void SpixOSRTest::prepare(int argc, char *argv[]) +{ + clearErrors(); + m_argc = argc; + m_argv = argv; +} + +SpixOSRTest &SpixOSRTest::instance() +{ + static SpixOSRTest inst; + return inst; +} + +std::vector SpixOSRTest::getAppErrors() const +{ + const auto &qtErrors = ErrorCollector::instance().errors(); + std::vector result; + result.reserve(qtErrors.size()); + + for (const auto &error : qtErrors) + { + result.push_back(error.toStdString()); + } + + return result; +} + +void SpixOSRTest::clearErrors() +{ + ErrorCollector::instance().clear(); +} + +int SpixOSRTest::testResult() +{ + return m_result.load(); +} + +void SpixOSRTest::executeTest() +{ + + ::testing::InitGoogleTest(&m_argc, m_argv); + auto testResult = RUN_ALL_TESTS(); + std::cout << "return code in executeTest(): " << testResult << std::endl; + m_result.store(testResult); +} + +TEST(UITest, ButtonTest) +{ + SpixOSRTest::instance().wait(std::chrono::milliseconds(2000)); + SpixOSRTest::instance().mouseClick(spix::ItemPath("gameScreen/menu/newButton")); + SpixOSRTest::instance().wait(std::chrono::milliseconds(3000)); + SpixOSRTest::instance().invokeMethod("gameScreen/view", "exitToMenu", std::vector{}); + SpixOSRTest::instance().wait(std::chrono::milliseconds(2000)); + SpixOSRTest::instance().mouseClick(spix::ItemPath("gameScreen/menu/exitButton")); + + EXPECT_EQ(SpixOSRTest::instance().getAppErrors(), std::vector{}); + EXPECT_EQ(SpixOSRTest::instance().getErrors(), std::vector{}); + + SpixOSRTest::instance().quit(); +} +} // namespace GUITest \ No newline at end of file diff --git a/tests/qml/SpixOSRTest.h b/tests/qml/SpixOSRTest.h new file mode 100644 index 00000000..0d657516 --- /dev/null +++ b/tests/qml/SpixOSRTest.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include +#include + +namespace GUITest +{ +class SpixOSRTest : public spix::TestServer +{ +public: + ~SpixOSRTest() override; + + static SpixOSRTest &instance(); + std::vector getAppErrors() const; + void clearErrors(); + int testResult(); + void prepare(int argc, char *argv[]); + +protected: + int m_argc{}; + char **m_argv{}; + std::atomic m_result{0}; + + void executeTest() override; + +private: + SpixOSRTest(); +}; +} // namespace GUITest \ No newline at end of file diff --git a/tests/qml/main.cpp b/tests/qml/main.cpp new file mode 100644 index 00000000..2c63f027 --- /dev/null +++ b/tests/qml/main.cpp @@ -0,0 +1,31 @@ +#include "ErrorCollector.h" +#include "SpixOSRTest.h" +#include +#include +#include + +namespace +{ +static const QString SETTINGS_ORGANIZATION = "OpenSR"; +static const QString SETTINGS_APPLICATION = "OpenSR"; +static const QString STARTUP_SCRIPT = "res:/opensrTestMode.js"; +static const QString MAIN_QML = "res:/OpenSR/GameWindow.qml"; +} // namespace + +int main(int argc, char *argv[]) +{ + OpenSR::Engine engine(argc, argv); + + QApplication::setOrganizationName(SETTINGS_ORGANIZATION); + QApplication::setApplicationName(SETTINGS_APPLICATION); + engine.setStartupScript(STARTUP_SCRIPT); + engine.setMainQML(MAIN_QML); + + GUITest::SpixOSRTest::instance().prepare(argc, argv); + auto bot = new spix::QtQmlBot(); + bot->runTestServer(GUITest::SpixOSRTest::instance()); + + engine.run(); + + return GUITest::SpixOSRTest::instance().testResult(); +} diff --git a/tools/DATTools/CMakeLists.txt b/tools/DATTools/CMakeLists.txt index 8a9028b9..2a01e189 100644 --- a/tools/DATTools/CMakeLists.txt +++ b/tools/DATTools/CMakeLists.txt @@ -1,3 +1,5 @@ +find_package(Qt6 REQUIRED COMPONENTS Core Gui) + add_executable(opensr-dat-convert crypt.cpp convert.cpp