Caméra

Regardez-le avec une caméra

viewport

Viewport

Dans l’article sur engine3, vous avez déjà pu voir à quoi ressemble la classe Camera. Une chose m’inquiétait et me laissait perplexe : comment j’allais tout mettre à l’échelle.

En bref, le problème est que chaque appareil a une résolution d’écran différente. À un moment, j’ai décidé que la hauteur maximale dans Photoshop serait de 240 pixels et la largeur illimitée. La configuration de love2d est ensuite réglée ainsi.

-- https://en.wikipedia.org/wiki/Display_resolution
--
-- 4:3
-- 1x 320 x 240 -- < minimal source background height
-- 2x 640 x 480
--
-- 16:9
-- 4x 1280x720
-- 6x 1920x1080 (fullHD)
-- 12x 3840x2160 (4K)
t.window.width = 1280 -- The window width (number)
t.window.height = 720 -- The window height (number)
t.window.usedpiscale = true -- Enable automatic DPI scaling (boolean)
t.window.highdpi = true -- Enable high-dpi mode for the window on a Retina display (boolean)
t.window.vsync = 1 -- Vertical sync mode (number)
t.window.msaa = 0 -- The number of samples to use with multi-sampled antialiasing (number)
t.window.depth = nil -- The number of bits per sample in the depth buffer
t.window.stencil = nil -- The number of bits per sample in the stencil buffer

Il est important de savoir que tout est dessiné en pixel perfect, ce qui permet d’agrandir les images avec ‘Nearest Neighbor (preserve hard edges)’. C’est un gros avantage par rapport au lissage, qui demanderait une méthode d’agrandissement compliquée.

Il est aussi important d’indiquer ceci dans love2d

love.graphics.setDefaultFilter("nearest", "nearest")
love.graphics.setLineStyle("rough")

Canvas

Dans love2d, j’utilise un canvas sur lequel tout le jeu est dessiné. La hauteur et la largeur du canvas sont définies par ma propre configuration, et Canvas est aussi une classe d’engine3.

config.lua

self.canvas = {
    width = 320,
    height = 240,
    integerScaling = true,
    subpixels = 4
}
function Canvas:new(data)
...
    self.canvas = love.graphics.newCanvas(self.width * self.subpixels, self.height * self.subpixels)
    self.canvas:setFilter("nearest", "nearest")
...
end

---Only execute this on init and window resize
function Canvas:calculateRenderInfo()
    local _, _, windowWidth, windowHeight = love.window.getSafeArea()
    local canvasWidth, canvasHeight = self:getDimensions()

    -- Fill as much of the window as possible with the canvas while preserving the aspect ratio.
    self.scale = math.min(windowWidth / canvasWidth, windowHeight / canvasHeight)
    --self.scale = windowHeight / canvasHeight -- This would fill the height and possibly cut off the sides.

    if self.integerScaling then
        self.scale = math.floor(self.scale * self:getSubpixels()) / self:getSubpixels()
        self.scale = math.max(self.scale, 1 / self:getSubpixels()) -- Avoid self.scale =0 if the window is tiny!
    end

    self.scaledWidth = canvasWidth * self.scale
    self.scaledHeight = canvasHeight * self.scale

    -- Center the canvas on the screen.
    self:setX(math.floor((windowWidth - self.scaledWidth) / 2))
    self:setY(math.floor((windowHeight - self.scaledHeight) / 2))

    -- These are cached now
    return self:getX(), self:getY(), self:getScale(), self:getPixelScale()
end
function love.resize(w, h)
    -- re-calculate so we cache the new variables
    -- @warning this is needed first, before we forward to the world
    self.canvas:calculateRenderInfo()

    -- forward to other entities
    if self.world then
        self.world:windowResizeEvent(
            {
                width = w,
                height = h
            }
        )
    end
end

Et c’est tout ce qu’il faut pour agrandir l’ensemble à l’infini à partir d’une hauteur d’écran minimale de 240 pixels. Je dois encore tester plusieurs écrans, mais jusqu’ici le rendu est bon sur différentes résolutions, et le redimensionnement de la fenêtre fonctionne bien aussi.

Caméra

J’ai réglé la caméra pour qu’elle puisse suivre une entity choisie, qui devient le centre vers lequel la caméra est orientée. Ce qui compte, c’est que le Canvas a toujours une hauteur et une largeur données, alors que la ‘Scene’ est beaucoup plus large, par exemple 2000px. La caméra reçoit donc aussi la largeur et la hauteur de la viewport (canvas) et, quand l’entity suivie bouge, elle ne bouge pas elle-même : elle change la façon dont love2d dessine toutes les coordonnées sur le canvas. On a ainsi l’illusion que la caméra se déplace vers la droite. En réalité, ce sont tous les objets qui se déplacent vers la gauche.

function Camera:attach()
    love.graphics.push("all")

    -- Scroll to the camera's position.
    local cornerX = self.x - self.width / 2
    local cornerY = self.y - self.height / 2
    -- Negative values because as the camera moves to the right, the world moves to the left etc.
    love.graphics.translate(
        Geometry.round(-cornerX, self.config.canvas.subpixels),
        Geometry.round(-cornerY, self.config.canvas.subpixels)
    )
end

function Camera:detach()
    love.graphics.pop()
end

---@param x integer
---@param y integer
function Camera:setTarget(x, y)
    if self:getWaitUntilOnTarget() then
        if Tween:isActive("cameraFollow") then
            print("camera is busy and getWaitUntilOnTarget is in use.")
            return
        end

        -- at the moment if you want to wait then you don't use the player static camera
        -- it will follow the camera from a to b
        self:setTargetX(x)
        self:setTargetY(y)

        Tween.to(self, self.followSpeed, {x = self.targetX, y = self.targetY}, "cameraFollow"):ease("linear"):onupdate(
            function()
                if self.bound then
                    self.x =
                        math.min(
                        math.max(self.x, self.bounds_min_x + self.width / 2),
                        self.bounds_max_x - self.width / 2
                    )
                    self.y =
                        math.min(
                        math.max(self.y, self.bounds_min_y + self.height / 2),
                        self.bounds_max_y - self.height / 2
                    )
                end
            end
        ):oncomplete(
            function()
                self:callback(self.callbackAfterStop, self)
            end
        )
    else
        self:setTargetX(x)
        self:setTargetY(y)

        self.x = self.targetX
        self.y = self.targetY

        -- Ensure the camera stays within bounds
        if self.bound then
            self.x = math.min(math.max(self.x, self.bounds_min_x + self.width / 2), self.bounds_max_x - self.width / 2)
            self.y =
                math.min(math.max(self.y, self.bounds_min_y + self.height / 2), self.bounds_max_y - self.height / 2)
        end
    end
end

Ordre de dessin

Vous avez déjà pu lire quelque chose sur le zindex dans un autre article, mais il s’agit ici de l’ordre des entities à l’écran, du canvas et de la caméra. Voici la boucle principale qui gère tout correctement.

...
function Engine3:draw()
	if self.world == nil then
		return
	end

	-- Draw to the canvas
	love.graphics.push("all")
	love.graphics.setCanvas(self.canvas:getCanvas())
	love.graphics.clear()
	love.graphics.scale(self.canvas:getSubpixels())

	if self.camera then
		self.camera:attach()
	end

	self.world:draw()

	-- Draw things on screen, not in the canvas and translations, for example a pointer
	for _, entity in pairs(self:getEntitiesToDraw("onCanvas")) do
		entity:draw()
	end

	if
		self.loadingScreen ~= nil and self.loadingScreen:getIsVisible() == true and
			self.loadingScreen:getRenderOtherLayers() == false
	 then
		self.loadingScreen:draw()
		self.loadingScreen.textObj:draw()
	end

	if self.camera then
		self.camera:detach()
		self.camera:draw() -- camera effects if needed
	end

	-- draw things within the canvas but without the moving camera
	for _, entity in pairs(self:getEntitiesToDraw("insideCanvasWithoutCameraMovement")) do
		entity:draw()
	end

	-- Canvas drawing end
	love.graphics.pop()
	love.graphics.clear(0, 0, 0)

	-- don't draw the canvas if there is a loadingScreen
	if
		self.loadingScreen == nil or self.loadingScreen:getIsVisible() == false or
			self.loadingScreen:getRenderOtherLayers() == true
	 then
		love.graphics.draw(self.canvas:getCanvas(), self.canvas:getX(), self.canvas:getY(), 0, self.canvas:getScale())

	-- debug
	-- love.graphics.rectangle(
	-- 	"line",
	-- 	self.canvas:getX(),
	-- 	self.canvas:getY(),
	-- 	self.canvas:getWidth() * self.canvas:getPixelScale(),
	-- 	self.canvas:getHeight() * self.canvas:getPixelScale()
	-- )
	end

	-- Draw things on screen, not in the canvas and translations, for example a pointer
	for _, entity in pairs(self:getEntitiesToDraw("onScreen")) do
		entity:draw()
	end

	if self.config.showFPS then
		love.graphics.print("FPS: " .. tostring(love.timer.getFPS()), 10, 10)
		love.graphics.print(
			string.format(
				"Memory: gfx=%.1fMiB lua=%.1fMiB",
				love.graphics.getStats().texturememory / 1024 ^ 2,
				collectgarbage "count" / 1024
			),
			10,
			25
		)
	end

	if self.config.showMouseXY then
		local mouseXOnScreen, mouseYOnScreen = love.mouse.getPosition()
		local text = string.format("\nMouse screen: %d,%d", mouseXOnScreen, mouseYOnScreen)
		if self.pointer then
			text = text .. string.format("\nScreenToWorld: %d,%d", self.pointer:getScreenToWorld(mouseXOnScreen, mouseYOnScreen))
		end

		love.graphics.print(text, 10, 30)
	end
end
...

Pour finir

La caméra a été un morceau de code difficile à comprendre. Heureusement, j’ai reçu beaucoup d’exemples et d’aide sur le forum de love2d. Sans ça, ça m’aurait sans doute pris trop de temps et je ne sais pas comment ça se serait terminé.

J’avais aussi un problème de jitter, c’est-à-dire une caméra instable. Je l’ai résolu en ne modifiant que le x pendant que la caméra suit l’entity.

-- don't jitter, we only move left <> right
local newTargetY = self.targetY
if self.targetY == nil then
    if self.followEntity.activeFrameset then
        newTargetY = self.followEntity.activeFrameset:getY()
    else
        newTargetY = self.followEntity:getY() or self.followEntity:getFrameY()
    end
end