Qt Quick 3D - Exemple de balle rapide
Démontre comment créer un jeu simple en utilisant Quick3D.

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.

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 :

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 !
© 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.