- Qt 6.7
- Qt Quick 3D
- Qt Quick 3D Examples and Tutorials
- Qt Quick 3D - Quick Ball Example
- Qt Quick 3D | Commercial or GPLv3

# 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();
             }
         }
     }