Kamera

Schau es dir mit einer Kamera an

Viewport

Viewport

Im Artikel über engine3 konntest du schon sehen, wie die Klasse Camera aussieht. Etwas, worüber ich mir Sorgen gemacht und Fragen gestellt habe, war, wie ich alles skalieren würde.

Kurz gesagt besteht das Problem darin, dass jedes Gerät eine andere Bildschirmauflösung hat. Irgendwann habe ich entschieden, dass die maximale Höhe in Photoshop 240 Pixel beträgt und die Breite unbegrenzt ist. Danach ist die Konfiguration von love2d so eingestellt.

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

Wichtig zu wissen ist, dass alles pixel perfect gezeichnet ist. Dadurch lassen sich die Bilder mit ‘Nearest Neighbor (preserve hard edges)’ hochskalieren. Das ist ein großer Vorteil gegenüber Glättung, denn dafür bräuchte man eine komplizierte Skalierungsmethode.

Wichtig ist auch, dass ich in love2d Folgendes angebe

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

Canvas

In love2d verwende ich ein Canvas, auf das das komplette Spiel gezeichnet wird. Höhe und Breite des Canvas werden durch meine eigene Konfiguration bestimmt, und Canvas ist ebenfalls eine engine3-Klasse.

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

Und das ist alles, um das Ganze von einer minimalen Bildschirmhöhe von 240 Pixeln aus beliebig zu vergrößern. Ich muss zwar noch mehrere Bildschirme testen, aber bisher sieht es auf verschiedenen Auflösungen gut aus, und auch das Ändern der Fenstergröße funktioniert gut.

Kamera

Die Kamera habe ich so eingestellt, dass sie einer gewählten Entity folgen kann, das ist also der Mittelpunkt, auf den die Kamera gerichtet ist. Wichtig ist, dass das Canvas immer eine bestimmte Höhe und Breite hat, die ‘Scene’ aber viel breiter ist, zum Beispiel 2000px. Die Kamera bekommt deshalb auch die Breite und Höhe des Viewports (Canvas) und bewegt sich nicht selbst, wenn die verfolgte Entity sich bewegt, sondern ändert die Art, wie love2d alle Koordinaten auf dem Canvas zeichnet. So entsteht die Illusion, dass sich die Kamera nach rechts bewegt. In Wirklichkeit bewegen sich aber alle Objekte nach links.

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

Zeichenreihenfolge

Über den zindex konntest du schon in einem anderen Artikel lesen, hier geht es aber um die Reihenfolge der Entities auf dem Bildschirm, des Canvas und der Kamera. Das ist die Hauptschleife, die alles korrekt abwickelt.

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

Zum Schluss

Die Kamera war ein schwer zu verstehendes Stück Code. Zum Glück habe ich im love2d-Forum viele Beispiele und Unterstützung bekommen. Ohne das hätte es mich wahrscheinlich zu viel Zeit gekostet, und ich weiß nicht, wie es ausgegangen wäre.

Außerdem hatte ich mit Jitter zu kämpfen, also einer unruhigen Kamera. Das habe ich gelöst, indem ich beim Verfolgen der Entity nur die x-Koordinate ändere.

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