Qt Quick 3D - Exemple de balle rapide

Démontre comment créer un jeu simple en utilisant Quick3D.

Scène de jeu avec balle et boîtes cibles portant le logo Qt

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

Le code source se trouve dans un seul fichier QML pour souligner à quel point cet exemple est compact, compte tenu du fait 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 ajuster pour voir comment elles affectent 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. View3D contient une fonction pour démarrer le jeu, qui (ré)initialise toutes les variables nécessaires et crée les cibles du niveau. Il contient également une fonction qui calcule les points finaux à la fin du jeu.

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 pour éclairer la scène. Il est positionné au-dessus des objets et réglé pour projeter des ombres. Notez que la luminosité est utilisée pour assombrir la zone de jeu à la fin de la partie. La propriété ambientColor est utilisée pour atténuer le contraste de la lumière, 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 lancement de la balle utilise l'élément Qt Quick 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();
    }
}

Ensuite, nous entrons dans les modèles 3D proprement dits. Le modèle de la balle est le plus important, car il contient la logique du comportement de la balle, ses animations et la détection des coups. Examinons d'abord les propriétés de la balle. Ball utilise un modèle de sphère intégré, mis à l'échelle sur la base de ballSize. Nous utilisons PrincipledMaterial avec baseColorMap et normalMap pour créer l'apparence d'une balle de tennis.

Combinaison de la carte des couleurs de base et de la carte des 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 que l'écran tactile est balayé, les dernières positions de maxMoves avant de relâcher la balle sont stockées dans le tableau moves. Lorsque l'utilisateur relâche la balle, throwBall() est appelé, ce qui calcule la direction de la balle à partir de ces dernières positions et commence à l'animer.

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 sur les différents axes. Ces animations utilisent les tableaux directionX et directionY précédemment assignés pour définir l'endroit où la balle se déplace, ainsi que speed pour le temps de vol de la balle. La position verticale a deux animations séquentielles, ce qui nous permet d'utiliser l'assouplissement pour le rebond de la balle. Lorsque les animations de position sont terminées, nous vérifions s'il reste des balles ou si le jeu doit se terminer. 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))
}

Une partie importante du jeu consiste à détecter le moment où la balle touche les cibles. Chaque fois que la position z de la balle change, nous parcourons en boucle 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 une touche, nous appelons la fonction target hit() 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 parce que dans les tableaux QML, les changements ne déclenchent pas de liaisons, de sorte que nous mettrons à 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 avec un modèle de cube et un élément de texte pour afficher les points. Comme pour le modèle de balle, nous utilisons les textures baseColorMap et normalMap pour créer des cubes avec un logo Qt. Lorsque le coup est détecté, nous animons séquentiellement le cube et affichons les points obtenus pour 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 l'aire de jeu. Le modèle du sol est un rectangle avec des textures d'herbe mises à l'échelle pour remplir 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 du ciel est plus éloigné, et nous ne voulons pas que des ombres soient projetées dans le ciel, c'est pourquoi nous fixons receivesShadows à false. Pour le ciel, nous ajoutons également quelques étoiles en utilisant Qt Quick Particles module. Tout comme les autres éléments 2D de Qt Quick, 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
            }
        }
    }
}

Lorsque nous combinons les modèles du sol et du ciel, nous obtenons un monde en 3D comme celui-ci :

Le ciel, le sol et les particules se combinent pour former l'environnement du jeu

Enfin, nous ajoutons des étincelles aux cibles, cette fois en utilisant le module QtQuick3D.Particles3D. ParticleSystem3D contient un SpriteParticle3D et nous leur allouons 200, ce qui est suffisant pour deux rafales de particules 100 simultanées. 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 Gravity3D affector pour entraîner les particules vers le bas avec une magnitude 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
    }
}

Ceci termine les parties 3D de notre jeu. Il reste quelques éléments 2D Qt Quick pour afficher le temps, les scores, le bouton de démarrage, etc. qui sont importants pour le jeu, mais qui ne sont pas pertinents pour cette documentation 3D rapide.

La balle est maintenant dans votre camp (jeu de mots). N'hésitez pas à étendre le jeu de différentes manières et à créer de nouveaux niveaux farfelus !

Exemple de projet @ 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.