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