Qt Quick 3D - Quick Ball Example

// Copyright (C) 2023 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

pragma ComponentBehavior: Bound

import QtQuick
import QtQuick3D
import QtQuick.Particles
import QtQuick3D.Particles3D
import QtQuick.Controls

Window {
    id: mainWindow

    // Scaling helpper
    readonly property real px: 0.2 + Math.min(width, height) / 800
    // This is false until the first game has started
    property bool playingStarted: false
    // This is true whenever game is on
    property bool gameOn: false
    // Sizes of our 3D models
    readonly property real ballSize: 40
    readonly property real targetSize: 120
    // Playing time in seconds
    readonly property real gameTime: 60
    property real currentTime: 0
    // Amount of balls per game
    readonly property int gameBalls: 20
    property int currentBalls: 0
    // Scores
    property int score: 0
    property int timeBonus: 0
    property int ballsBonus: 0

    width: 800
    height: 600
    visible: true
    title: qsTr("Quick3D Quick Ball")
    color: "#000000"

    View3D {
        id: view3D
        anchors.fill: parent

        function createLevel1() {
            // Simple level of target items
            var level1 = [{ "x": 0, "y": 100, "z": -100, "points": 10 },
                          { "x": -300, "y": 100, "z": -400, "points": 10 },
                          { "x": 300, "y": 100, "z": -400, "points": 10 },
                          { "x": -200, "y": 400, "z": -600, "points": 20 },
                          { "x": 0, "y": 400, "z": -600, "points": 20 },
                          { "x": 200, "y": 400, "z": -600, "points": 20 },
                          { "x": 0, "y": 700, "z": -600, "points": 30 }];
            targetsNode.addTargets(level1);
        }

        function startGame() {
            ballModel.resetBall();
            targetsNode.resetTargets();
            createLevel1();
            score = timeBonus = ballsBonus = 0;
            currentBalls = gameBalls;
            gameOn = true;
            playingStarted = true;
        }

        function endGame() {
            if (targetsNode.currentTargets == 0) {
                // If we managed to get all targets down -> bonus points!
                timeBonus = mainWindow.currentTime;
                ballsBonus = currentBalls * 10;
            }
            gameOn = false;
        }

        environment: SceneEnvironment {
            antialiasingMode: SceneEnvironment.MSAA
            antialiasingQuality: SceneEnvironment.High
        }

        camera: viewCamera

        PerspectiveCamera {
            id: viewCamera
            position: Qt.vector3d(0, 200, 800);

            // Rotate camera a bit
            SequentialAnimation on eulerRotation.y {
                loops: Animation.Infinite
                NumberAnimation {
                    to: 2
                    duration: 2000
                    easing.type: Easing.InOutQuad
                }
                NumberAnimation {
                    to: -2
                    duration: 2000
                    easing.type: Easing.InOutQuad
                }
            }
        }

        PointLight {
            x: 400
            y: 1200
            castsShadow: true
            shadowMapQuality: Light.ShadowMapQualityHigh
            shadowFactor: 50
            quadraticFade: 2
            ambientColor: "#202020"
            brightness: mainWindow.gameOn ? 200 : 40
            Behavior on brightness {
                NumberAnimation {
                    duration: 1000
                    easing.type: Easing.InOutQuad
                }
            }
        }

        MouseArea {
            anchors.fill: parent
            enabled: mainWindow.gameOn && !ballModel.ballMoving
            onPressed: {
                ballModel.moveBall(mouseX, mouseY);
            }
            onPositionChanged: {
                ballModel.moveBall(mouseX, mouseY);
            }
            onReleased: {
                ballModel.throwBall();
            }
        }

        Model {
            id: ballModel
            property real directionX: 0
            property real directionY: 0
            // How many ms the ball flies
            readonly property real speed: 2000
            readonly property real ballScale: mainWindow.ballSize / 100
            property var moves: []
            readonly property int maxMoves: 5
            readonly property bool ballMoving: ballAnimation.running

            source: "#Sphere"
            scale: Qt.vector3d(ballScale, ballScale, ballScale)

            materials: DefaultMaterial {
                diffuseMap: Texture {
                    source: "images/ball.jpg"
                }
                normalMap: Texture {
                    source: "images/ball_n.jpg"
                }
                bumpAmount: 1.0
            }

            function resetBall() {
                moves = [];
                x = 0;
                y = mainWindow.ballSize/2;
                z = 400;
            }

            function moveBall(posX, posY) {
                var pos = view3D.mapTo3DScene(Qt.vector3d(posX, posY, ballModel.z + mainWindow.ballSize));
                pos.y = Math.max(mainWindow.ballSize / 2, pos.y);
                var point = {"x": pos.x, "y": pos.y };
                moves.push(point);
                if (moves.length > maxMoves) moves.shift();
                // Apply position into ball model
                ballModel.x = pos.x;
                ballModel.y = pos.y;
            }

            function throwBall() {
                mainWindow.currentBalls--;
                var moveX = 0;
                var moveY = 0;
                if (moves.length >= 2) {
                    var first = moves.shift();
                    var last = moves.pop();
                    moveX = last.x - first.x;
                    moveY = last.y - first.y;
                    if (moveY < 0) moveY = 0;
                }
                directionX = moveX * 20;
                directionY = moveY * 4;
                ballAnimation.start();
            }

            ParallelAnimation {
                id: ballAnimation
                running: false
                // Move forward
                NumberAnimation {
                    target: ballModel
                    property: "z"
                    duration: ballModel.speed
                    to: -ballModel.directionY * 5
                    easing.type: Easing.OutQuad
                }
                // Move up & down with a bounce
                SequentialAnimation {
                    NumberAnimation {
                        target: ballModel
                        property: "y"
                        duration: ballModel.speed * (1 / 3)
                        to: ballModel.y + ballModel.directionY
                        easing.type: Easing.OutQuad
                    }
                    NumberAnimation {
                        target: ballModel
                        property: "y"
                        duration: ballModel.speed * (2 / 3)
                        to: mainWindow.ballSize / 4
                        easing.type: Easing.OutBounce
                    }
                }
                // Move sideways
                NumberAnimation {
                    target: ballModel
                    property: "x"
                    duration: ballModel.speed
                    to: ballModel.x + ballModel.directionX
                }

                onFinished: {
                    if (mainWindow.currentBalls <= 0)
                        view3D.endGame();
                    ballModel.resetBall();
                }
            }

            NumberAnimation on eulerRotation.z {
                running: ballModel.ballMoving
                loops: Animation.Infinite
                from: ballModel.directionX < 0 ? 0 : 720
                to: 360
                duration: 10000 / (2 + Math.abs(ballModel.directionX * 0.05))
            }

            onZChanged: {
                // Loop through target items and detect collisions
                var hitMargin = mainWindow.ballSize / 2 + mainWindow.targetSize / 2;
                for (var i = 0; i < targetsNode.targets.length; ++i) {
                    var target = targetsNode.targets[i];
                    var targetPos = target.scenePosition;
                    var hit = ballModel.scenePosition.fuzzyEquals(targetPos, hitMargin);
                    if (hit) {
                        target.hit();
                        if (targetsNode.currentTargets <= 0)
                            view3D.endGame();
                    }
                }
            }
        }

        Node {
            id: targetsNode

            property var targets: []
            property int currentTargets: 0

            function addTargets(items) {
                items.forEach(function (item) {
                    let instance = targetComponent.createObject(
                            targetsNode, { "x": item.x, "startPosY": item.y, "z": item.z, "points": item.points});
                    targets.push(instance);
                });
                currentTargets = targets.length;
            }

            function removeTarget(item) {
                var index = targets.indexOf(item);
                targets.splice(index, 1);
                currentTargets = targets.length;
            }

            function resetTargets() {
                while (targets.length > 0)
                    targets.pop().destroy();
                currentTargets = targets.length;
            }
        }

        Component {
            id: targetComponent
            Node {
                id: targetNode

                property int points: 0
                property real hide: 0
                property real startPosY: 0
                property real posY: 0
                property real pointsOpacity: 0

                function hit() {
                    targetsNode.removeTarget(this);
                    mainWindow.score += points;
                    hitAnimation.start();
                    var burstPos = targetNode.mapPositionToScene(Qt.vector3d(0, 0, 0));
                    hitParticleEmitter.burst(100, 200, burstPos);
                }

                y: startPosY + posY
                SequentialAnimation {
                    running: mainWindow.gameOn && !hitAnimation.running
                    loops: Animation.Infinite
                    NumberAnimation {
                        target: targetNode
                        property: "posY"
                        from: 0
                        to: 150
                        duration: 3000
                        easing.type: Easing.InOutQuad
                    }
                    NumberAnimation {
                        target: targetNode
                        property: "posY"
                        to: 0
                        duration: 1500
                        easing.type: Easing.InOutQuad
                    }
                }

                SequentialAnimation {
                    id: hitAnimation
                    NumberAnimation {
                        target: targetNode
                        property: "hide"
                        to: 1
                        duration: 800
                        easing.type: Easing.InOutQuad
                    }
                    NumberAnimation {
                        target: targetNode
                        property: "pointsOpacity"
                        to: 1
                        duration: 1000
                        easing.type: Easing.InOutQuad
                    }
                    NumberAnimation {
                        target: targetNode
                        property: "pointsOpacity"
                        to: 0
                        duration: 200
                        easing.type: Easing.InOutQuad
                    }
                    ScriptAction {
                        script: targetNode.destroy();
                    }
                }

                Model {
                    id: targetModel

                    readonly property real targetScale: (1 + targetNode.hide) * (mainWindow.targetSize / 100)

                    source: "#Cube"
                    scale: Qt.vector3d(targetScale, targetScale, targetScale)
                    opacity: 0.99 - targetNode.hide * 2
                    materials: DefaultMaterial {
                        diffuseMap: Texture {
                            source: "images/qt_logo.jpg"
                        }
                        normalMap: Texture {
                            source: "images/qt_logo_n.jpg"
                        }
                        bumpAmount: 1.0
                    }
                    Vector3dAnimation on eulerRotation {
                        loops: Animation.Infinite
                        duration: 5000
                        from: Qt.vector3d(0, 0, 0)
                        to: Qt.vector3d(360, 360, 360)
                    }
                }
                Text {
                    anchors.centerIn: parent
                    scale: 1 + targetNode.pointsOpacity
                    opacity: targetNode.pointsOpacity
                    text: targetNode.points
                    font.pixelSize: 60 * mainWindow.px
                    color: "#808000"
                    style: Text.Outline
                    styleColor: "#f0f000"
                }
            }
        }

        Model {
            source: "#Rectangle"
            scale: Qt.vector3d(50, 50, 1)
            eulerRotation.x: -90
            materials: DefaultMaterial {
                diffuseMap: Texture {
                    source: "images/grass.jpg"
                    tilingModeHorizontal: Texture.Repeat
                    tilingModeVertical: Texture.Repeat
                    scaleU: 25.0
                    scaleV: 25.0
                }
                normalMap: Texture {
                    source: "images/grass_n.jpg"
                }
                bumpAmount: 0.6
            }
        }

        Model {
            id: sky
            property real scaleX: 100
            property real scaleY: 20
            source: "#Rectangle"
            scale: Qt.vector3d(sky.scaleX, sky.scaleY, 1)
            position: Qt.vector3d(0, 960, -2000)
            // We don't want shadows casted into sky
            receivesShadows: false
            materials: DefaultMaterial {
                diffuseMap: Texture {
                    source: "images/sky.jpg"
                }
            }
            // Star particles
            Node {
                z: 500
                y: 30
                // Stars are far away, scale up to half the resolution
                scale: Qt.vector3d(2 / sky.scaleX, 2 / sky.scaleY, 1)
                ParticleSystem {
                    anchors.horizontalCenter: parent.horizontalCenter
                    anchors.top: parent.top
                    width: 3000
                    height: 400
                    ImageParticle {
                        source: "qrc:///particleresources/star.png"
                        rotationVariation: 360
                        color: "#ffffa0"
                        colorVariation: 0.1
                    }
                    Emitter {
                        anchors.fill: parent
                        emitRate: 4
                        lifeSpan: 6000
                        lifeSpanVariation: 4000
                        size: 30
                        sizeVariation: 20
                    }
                }
            }
        }

        ParticleSystem3D {
            id: psystem
            SpriteParticle3D {
                id: sprite
                sprite: Texture {
                    source: "images/particle.png"
                }
                color: Qt.rgba(1.0, 1.0, 0.0, 1.0)
                colorVariation: Qt.vector4d(0.4, 0.6, 0.0, 0.0)
                unifiedColorVariation: true
                maxAmount: 200
            }
            ParticleEmitter3D {
                id: hitParticleEmitter
                particle: sprite
                particleScale: 4.0
                particleScaleVariation: 2.0
                particleRotationVariation: Qt.vector3d(0, 0, 180)
                particleRotationVelocityVariation: Qt.vector3d(0, 0, 250)
                velocity: VectorDirection3D {
                    direction: Qt.vector3d(0, 300, 0)
                    directionVariation: Qt.vector3d(200, 150, 100)
                }
                lifeSpan: 800
                lifeSpanVariation: 200
                depthBias: 100
            }
            Gravity3D {
                magnitude: 600
            }
        }
    }

    // Game time counter
    NumberAnimation {
        target: mainWindow
        property: "currentTime"
        running: mainWindow.gameOn
        duration: mainWindow.gameTime * 1000
        from: mainWindow.gameTime
        to: 0
        onFinished: {
            view3D.endGame();
        }
    }

    // Show time, balls and score
    Item {
        width: parent.width
        height: 60 * mainWindow.px
        Text {
            anchors.verticalCenter: parent.verticalCenter
            anchors.left: parent.left
            anchors.leftMargin: 20 * mainWindow.px
            font.pixelSize: 26 * mainWindow.px
            color: "#ffffff"
            style: Text.Outline
            styleColor: "#000000"
            text: mainWindow.currentTime.toFixed(2)
        }
        Image {
            anchors.verticalCenter: parent.verticalCenter
            anchors.verticalCenterOffset: 1 * mainWindow.px
            anchors.right: ballCountText.left
            anchors.rightMargin: 8 * mainWindow.px
            width: 26 * mainWindow.px
            height: width
            mipmap: true
            source: "images/ball_icon.png"
        }
        Text {
            id: ballCountText
            anchors.verticalCenter: parent.verticalCenter
            anchors.right: parent.right
            anchors.rightMargin: 20 * mainWindow.px
            font.pixelSize: 26 * mainWindow.px
            color: "#ffffff"
            style: Text.Outline
            styleColor: "#000000"
            text: mainWindow.currentBalls
        }
        Text {
            anchors.centerIn: parent
            font.pixelSize: 36 * mainWindow.px
            color: "#ffffff"
            style: Text.Outline
            styleColor: "#000000"
            text: mainWindow.score
        }
    }

    // Game logo
    Image {
        anchors.centerIn: parent
        width: Math.min(parent.width * 0.6, sourceSize.width)
        height: width * 0.6
        fillMode: Image.PreserveAspectFit
        source: "images/quickball.png"
        opacity: !mainWindow.gameOn
        scale: 2.0 - opacity
        Behavior on opacity {
            NumberAnimation {
                duration: 400
                easing.type: Easing.InOutQuad
            }
        }
    }

    // Show bonus and total score when the game ends
    Item {
        property bool show: mainWindow.playingStarted && !mainWindow.gameOn

        anchors.centerIn: parent
        anchors.verticalCenterOffset: -200 * mainWindow.px
        onShowChanged: {
            if (show) {
                showScoreAnimation.start();
            } else {
                showScoreAnimation.stop();
                timeBonusText.opacity = 0;
                ballsBonusText.opacity = 0;
                totalScoreText.opacity = 0;
            }
        }

        SequentialAnimation {
            id: showScoreAnimation
            NumberAnimation {
                target: timeBonusText
                property: "opacity"
                to: 1
                duration: 1000
                easing.type: Easing.InOutQuad
            }
            NumberAnimation {
                target: ballsBonusText
                property: "opacity"
                to: 1
                duration: 1000
                easing.type: Easing.InOutQuad
            }
            NumberAnimation {
                target: totalScoreText
                property: "opacity"
                to: 1
                duration: 1000
                easing.type: Easing.InOutQuad
            }
        }

        Text {
            id: timeBonusText
            anchors.horizontalCenter: parent.horizontalCenter
            y: opacity * 60 * mainWindow.px
            font.pixelSize: 26 * mainWindow.px
            color: "#ffffff"
            style: Text.Outline
            styleColor: "#000000"
            textFormat: Text.StyledText
            text: qsTr("TIME BONUS <b>%1</b>").arg(mainWindow.timeBonus)
            opacity: 0
        }
        Text {
            id: ballsBonusText
            anchors.horizontalCenter: parent.horizontalCenter
            y: timeBonusText.y + opacity * 40 * mainWindow.px
            font.pixelSize: 26 * mainWindow.px
            color: "#ffffff"
            style: Text.Outline
            styleColor: "#000000"
            textFormat: Text.StyledText
            text: qsTr("BALLS BONUS <b>%1</b>").arg(mainWindow.ballsBonus)
            opacity: 0
        }
        Text {
            id: totalScoreText
            anchors.horizontalCenter: parent.horizontalCenter
            y: ballsBonusText.y + opacity * 60 * mainWindow.px
            font.pixelSize: 66 * mainWindow.px
            color: "#ffffff"
            style: Text.Outline
            styleColor: "#000000"
            textFormat: Text.StyledText
            text: qsTr("SCORE <b>%1</b>").arg(mainWindow.score + mainWindow.timeBonus + mainWindow.ballsBonus)
            opacity: 0
        }
    }

    RoundButton {
        anchors.horizontalCenter: parent.horizontalCenter
        anchors.bottom: parent.bottom
        anchors.bottomMargin: 40 * mainWindow.px
        width: 140 * mainWindow.px
        height: 60 * mainWindow.px
        visible: !mainWindow.gameOn
        font.pixelSize: 26 * mainWindow.px
        text: qsTr("START")
        onClicked: {
            view3D.startGame();
        }
    }
}