5 minute(s) de lecture

Je crée en ce moment un jeu avec mon fils en m’appuyant sur Solar 2D ; un jeu pour l’instant simple dans lequel il est possible de lancer un projectile en ajustant son coup au préalable, à l’instar du lancé de pigeon dans un jeu comme Angry Birds. J’ai ajouté une prédiction de trajectoire en partant du tutorial existant dans la documentation officielle mais plusieurs points m’ont amené à revoir ma copie : un calcul de trajectoire à adapter en fonction de la configuration display.fps ; une incompatibilité avec un lancement de projectile utilisant applyLinearImpulse plutôt que setLinearVelocity ; une mise à jour de la prédiction qui suppose que le projectile à lancer n’est pas en mouvement.

Gestion des évènements

Pour mettre à jour la prédiction de trajectoire avec un projectile en mouvement, j’utilise deux évènements : touch pour déterminer ce que le joueur souhaite faire et lateUpdate pour réaliser la prédiction de trajectoire.

-- ballImpulseForce est utilisé pour le calcul de la préduction lors de l'évènement lateUpdate
local ballImpulseForce = nil

local handleBallImpulseOnScreenTouch
local predictBallPathOnLateUpdate
local removePredictedBallPath

handleBallImpulseOnScreenTouch = function(event)
  -- Récupération de la distance et de la direction du lancé de projectile. Avec cette formule,
  -- le joueur tire en arrière pour lancer vers l'avant. Je multiplie par 2 pour augmenter la force
  local _ballImpulseForce = { x = (event.xStart - event.x) * 2, y = (event.yStart - event.y) * 2 }

  -- Par défaut rien n'est fait lors de l'évènement lateUpdate
  ballImpulseForce = nil

  if (event.phase == "ended") then
    -- ... Lancement du projectile
  elseif (event.phase == "moved") then
    -- La prédiction de trajectoire se fait uniquement lors de la phase moved de l'évènement touch
    ballImpulseForce = _ballImpulseForce
  end

  return true
end

predictBallPathOnLateUpdate = function()
  -- Prédiction de trajectoire uniquement lors de la phase moved de l'évènement touch
  if not ballImpulseForce then
    return false
  end
  -- ... Prédiction de la trajectoire
end

function scene:show(event)
  if event.phase == "did" then
    Runtime:addEventListener("touch", handleBallImpulseOnScreenTouch)
    Runtime:addEventListener("lateUpdate", predictBallPathOnLateUpdate)
  end
end

function scene:hide(event)
  if event.phase == "did" then
    Runtime:removeEventListener("lateUpdate", predictBallPathOnLateUpdate)
    Runtime:removeEventListener("touch", handleBallImpulseOnScreenTouch)
  end
end

Prédiction de la trajectoire

J’utilise deux formules pour prédire la trajectoire du mon projectile scene.ball :

  1. Acceleration = Force / Masse
  2. Distance parcourue du fait de la gravité = (Gravité * Temps²) / 2
local fromMKS = physics.fromMKS
local toMKS = physics.toMKS

predictBallPathOnLateUpdate = function()
  if not ballImpulseForce then
    return false
  end

  -- Le temps en seconde entre les points qui seront affichés à l'écran
  local timeStepInterval = 0.1
  local gravityX, gravityY = physics.getGravity()

  -- La vélocité actuelle du projectile qui peut être en mouvement
  local velocityX, velocityY = scene.ball:getLinearVelocity()

  -- Suppression de la prédiction précédente
  removePredictedBallPath()

  -- Création du groupe auquel seront rattachés tous les points de la trajectoire
  scene.predictedBallPath = display.newGroup()
  scene.view:insert(scene.predictedBallPath)

  local prevStepX = nil
  local prevStepY = nil

  -- Je vais de 0 à 10 pour prédire une trajectoire sur un temps d'environ une seconde, cela peut être ajusté
  -- en modifiant timeStepInterval ou en jouant sur l'intervalle de la boucle for
  for step = 0, 10, 1 do
    local time = step * timeStepInterval

    -- Calcul de l'accélération
    local accelerationX = ballImpulseForce.x / scene.ball.mass
    local accelerationY = ballImpulseForce.y / scene.ball.mass

    -- Calcul des coordonnées du point de la trajectoire
    -- scene.ball.x : la position actuelle du projectile
    -- time * velocityX : la position future du projectile étant donné sa vitesse actuelle
    -- time * accelerationX : la position future du projectile étant donné son accelération
    -- 0.5 * fromMKS("velocity", gravityX) * (time * time) : la distance parcourue du fait de la gravité
    local stepX = scene.ball.x
    stepX = stepX + time * velocityX
    stepX = stepX + time * accelerationX
    stepX = stepX + 0.5 * fromMKS("velocity", gravityX) * (time * time)

    local stepY = scene.ball.y
    stepY = stepY + time * velocityY
    stepY = stepY + time * accelerationY
    stepY = stepY + 0.5 * fromMKS("velocity", gravityY) * (time * time)

    -- Détection d'un éventuel obstacle entre ce point et le précédent
    if step > 0 and physics.rayCast(prevStepX, prevStepY, stepX, stepY, "any") then
      break
    end

    prevStepX = stepX
    prevStepY = stepY

    -- Ajout d'un point de prédiction de la trajectoire si aucun obstacle n'a été détecté
    display.newCircle(scene.predictedBallPath, stepX, stepY, 2)
  end

  return false
end

removePredictedBallPath = function()
  display.remove(scene.predictedBallPath)
  scene.predictedBallPath = nil
end

Lancement du projectile

Lorsque le joueur relâche son doigt, le projectile est lancé.

handleBallImpulseOnScreenTouch = function(event)
  if (event.phase == "ended") then
    -- Suppression de la prédiction de trajectoire
    removePredictedBallPath()

    -- Impulsion sur le projectile en convertissant les pixels en mètres pour le moteur physique
    scene.ball:applyLinearImpulse(
      toMKS("velocity", _ballImpulseForce.x),
      toMKS("velocity", _ballImpulseForce.y),
      scene.ball.x,
      scene.ball.y
    )
  end
end

Code complet

local composer = require "composer"
local physics = require "physics"

local ballImpulseForce = nil
local fromMKS = physics.fromMKS
local scene = composer.newScene()
local toMKS = physics.toMKS

local handleBallImpulseOnScreenTouch
local predictBallPathOnLateUpdate
local removePredictedBallPath

handleBallImpulseOnScreenTouch = function(event)
  local _ballImpulseForce = { x = (event.xStart - event.x) * 2, y = (event.yStart - event.y) * 2 }
  ballImpulseForce = nil

  if (event.phase == "ended") then
    removePredictedBallPath()

    scene.ball:applyLinearImpulse(
      toMKS("velocity", _ballImpulseForce.x),
      toMKS("velocity", _ballImpulseForce.y),
      scene.ball.x,
      scene.ball.y
    )
  elseif (event.phase == "moved") then
    ballImpulseForce = _ballImpulseForce
  end

  return true
end

predictBallPathOnLateUpdate = function()
  if not ballImpulseForce then
    return false
  end

  local timeStepInterval = 0.1
  local gravityX, gravityY = physics.getGravity()
  local velocityX, velocityY = scene.ball:getLinearVelocity()

  removePredictedBallPath()
  scene.predictedBallPath = display.newGroup()
  scene.view:insert(scene.predictedBallPath)

  local prevStepX = nil
  local prevStepY = nil

  for step = 0, 10, 1 do
    local time = step * timeStepInterval
    local accelerationX = ballImpulseForce.x / scene.ball.mass
    local accelerationY = ballImpulseForce.y / scene.ball.mass

    local stepX = scene.ball.x
    stepX = stepX + time * velocityX
    stepX = stepX + time * accelerationX
    stepX = stepX + 0.5 * fromMKS("velocity", gravityX) * (time * time)

    local stepY = scene.ball.y
    stepY = stepY + time * velocityY
    stepY = stepY + time * accelerationY
    stepY = stepY + 0.5 * fromMKS("velocity", gravityY) * (time * time)

    if step > 0 and physics.rayCast(prevStepX, prevStepY, stepX, stepY, "any") then
      break
    end

    prevStepX = stepX
    prevStepY = stepY

    display.newCircle(scene.predictedBallPath, stepX, stepY, 2)
  end

  return false
end

removePredictedBallPath = function()
  display.remove(scene.predictedBallPath)
  scene.predictedBallPath = nil
end

function scene:create(event)
  physics.start()
  physics.pause()
  physics.setGravity(0, 9.8)

  -- ...

  self:createBall()
end

function scene:createBall()
  self.ball = display.newImageRect(self.view, "images/ball.png", 40, 40)
  self.ball.x = display.contentWidth / 2
  self.ball.y = display.contentHeight / 2

  physics.addBody(self.ball, {
    radius = self.ball.width / 2 - 1,
    density = 1.0,
    friction = 0.3,
    bounce = 0.5,
  })

  self.ball.angularDamping = 1.5
end

function scene:show(event)
  if event.phase == "did" then
    Runtime:addEventListener("touch", handleBallImpulseOnScreenTouch)
    Runtime:addEventListener("lateUpdate", predictBallPathOnLateUpdate)
    physics.start()
  end
end

function scene:hide(event)
  if event.phase == "did" then
    Runtime:removeEventListener("lateUpdate", predictBallPathOnLateUpdate)
    Runtime:removeEventListener("touch", handleBallImpulseOnScreenTouch)
    physics.stop()

    composer.removeScene("game")
  end
end

function scene:destroy(event)
  package.loaded[physics] = nil
  physics = nil
end

scene:addEventListener("create", scene)
scene:addEventListener("show", scene)
scene:addEventListener("hide", scene)
scene:addEventListener("destroy", scene)

return scene

Laisser un commentaire