Qt Quick 3D - Exemple de « Quick Ball »

Cet exemple montre comment créer un jeu simple à l'aide de Quick3D.

Scène de jeu avec une balle et des cibles portant le logo Qt

Cet exemple montre comment associer Qt Quick et Qt Quick 3D pour créer un jeu 3D simple. Le but du jeu est de toucher des cibles en lançant une balle. Les points sont attribués en fonction de la rapidité avec laquelle toutes les cibles sont touchées et du nombre de balles utilisées. Visez bien, mais soyez rapide !

Le code source est contenu dans un seul fichier QML afin de souligner la concision de cet exemple, d’autant plus qu’il s’agit d’un jeu entièrement jouable. Commençons par examiner les principales propriétés. Celles-ci sont assez explicites, et vous pouvez facilement les modifier pour voir comment elles influencent le jeu.

// 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

La logique du jeu est implémentée en JavaScript. Le fichier ` View3D ` contient une fonction permettant de lancer le jeu, qui (re)initialise toutes les variables nécessaires et crée les cibles du niveau. Il contient également une fonction permettant de calculer le score final à la fin de la partie.

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();
    mainWindow.score = mainWindow.timeBonus = mainWindow.ballsBonus = 0;
    mainWindow.currentBalls = mainWindow.gameBalls;
    mainWindow.gameOn = true;
    mainWindow.playingStarted = true;
}

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

La vue contient également un nœud « PointLight » destiné à éclairer la scène. Il est positionné au-dessus des objets et configuré pour projeter des ombres. Remarquez comment la luminosité est utilisée pour assombrir la zone de jeu une fois la partie terminée. La propriété « ambientColor » sert à adoucir le contraste lumineux, car sans elle, les parties inférieures des objets seraient très sombres.

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
        }
    }
}

Le lancer de la balle utilise l’élément « Qt Quick » de la propriété « MouseArea », qui n’est activé que lorsque le jeu est en cours et que la balle n’est pas déjà en mouvement.

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

Passons ensuite aux modèles 3D proprement dits. Le modèle de la balle est le plus volumineux, car il contient la logique de comportement de la balle, ses animations et la détection des impacts. Examinons d’abord les propriétés de la balle. La balle utilise un modèle de sphère intégré, redimensionné à l’aide de l’ ballSize. Nous utilisons l’ PrincipledMaterial avec un baseColorMap et un normalMap pour créer l’apparence d’une balle de tennis.

Combinaison d'une carte de couleur de base et d'une carte de normales pour créer la texture d'une balle de tennis

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: PrincipledMaterial {
        baseColorMap: Texture {
            source: "images/ball.jpg"
        }
        normalMap: Texture {
            source: "images/ball_n.jpg"
        }
        normalStrength: 1.0
    }

Lorsque la souris est déplacée ou qu'un glissement est effectué sur l'écran tactile, les dernières positions d’ maxMoves avant le lâcher de la balle sont stockées dans le tableau moves. Lorsque l’utilisateur lâche la balle, la fonction throwBall() est appelée ; celle-ci calcule la direction de la balle à partir de ces dernières positions et lance son animation.

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

function moveBall(posX, posY) {
    if (!mainWindow.gameOn) return;
    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() {
    if (!mainWindow.gameOn) return;
    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();
}

La position de la balle est animée séparément selon les différents axes. Ces animations utilisent les variables directionX et directionY, définies précédemment, pour déterminer la trajectoire de la balle, ainsi que la fonction speed pour calculer la durée de son vol. La position verticale comporte deux animations séquentielles, ce qui permet d’utiliser un effet d’accélération/décélération pour le rebond de la balle. Une fois les animations de position terminées, nous vérifions s’il reste des balles ou si la partie doit prendre fin. Enfin, nous animons également la rotation de la balle, afin que l'utilisateur puisse lancer des balles courbes.

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

Un aspect important du jeu consiste à détecter quand la balle touche les cibles. Chaque fois que la position z de la balle change, nous parcourons le tableau targets et détectons si la balle touche l’une d’entre elles à l’aide de fuzzyEquals(). Chaque fois que nous détectons un impact, nous appelons la fonction d’ hit() de la cible et vérifions si toutes les cibles sont à terre.

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

Nous pouvons alors passer aux cibles. Celles-ci sont générées dynamiquement dans un nœud de regroupement qui contient des fonctions d’aide et permet, par exemple, d’animer toutes les cibles en tant que groupe. Notez que la propriété currentTargets est nécessaire car, dans les tableaux QML, les modifications ne déclenchent pas de liaisons ; nous mettrons donc à jour le nombre de cibles manuellement.

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

Les cibles sont des nœuds comportant un modèle de cube et un élément de texte permettant d’afficher les points. À l’instar du modèle de balle, nous utilisons les textures ` baseColorMap ` et ` normalMap ` pour créer des cubes arborant le logo Qt. Lorsqu’un impact est détecté, nous animons le cube pour qu’il s’éloigne progressivement et affichons les points gagnés grâce à cette cible. Une fois l’animation terminée, nous supprimons dynamiquement le nœud de la cible.

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: PrincipledMaterial {
                baseColorMap: Texture {
                    source: "images/qt_logo.jpg"
                }
                normalMap: Texture {
                    source: "images/qt_logo_n.jpg"
                }
                normalStrength: 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"
        }
    }
}

Nous avons également besoin de quelques modèles pour la zone de jeu. Le modèle de sol est un rectangle dont les textures d'herbe sont redimensionnées pour couvrir une plus grande surface.

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

Le modèle de ciel se trouve plus en arrière, et comme nous ne voulons pas que des ombres se projettent dans le ciel, nous définissons l'option « receivesShadows » sur « false ». Pour le ciel, nous ajoutons également des étoiles à l'aide du Qt Quick Particles module. À l’instar des autres éléments d’ Qt Quick 2D, les particules peuvent également être ajoutées directement à l’intérieur des nœuds 3D.

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: PrincipledMaterial {
        baseColorMap: 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
            }
        }
    }
}

En combinant les modèles de sol et de ciel ci-dessus, nous obtenons un monde en 3D comme celui-ci :

Le ciel, le sol et les particules s'associent pour former l'environnement du jeu

Enfin, nous ajoutons quelques étincelles pour les cibles, cette fois à l’aide du module « QtQuick3D.Particles3D ». « ParticleSystem3D » contient un « SpriteParticle3D » et nous leur allouons « 200 », ce qui suffit pour deux salves de particules simultanées « 100 ». « ParticleEmitter3D » définit les propriétés d’émission des particules telles que l’échelle, la rotation, la vitesse et la durée de vie. Nous ajoutons également un affecteur « Gravity3D » pour faire descendre les particules avec une amplitude appropriée.

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
    }
}

Cela conclut la partie 3D de notre jeu. Il reste encore quelques éléments d’ Qt Quick 2D pour afficher l’heure, les scores, le bouton de démarrage, etc., qui sont importants pour le jeu, mais ne relèvent pas de cette documentation « Quick 3D ».

C’est désormais à vous de jouer (jeu de mots intentionnel). N’hésitez pas à développer le jeu de différentes manières et à créer de nouveaux niveaux farfelus !

Exemple de projet sur code.qt.io

© 2026 The Qt Company Ltd. Documentation contributions included herein are the copyrights of their respective owners. The documentation provided herein is licensed under the terms of the GNU Free Documentation License version 1.3 as published by the Free Software Foundation. Qt and respective logos are trademarks of The Qt Company Ltd. in Finland and/or other countries worldwide. All other trademarks are property of their respective owners.