love.graphics.setDefaultFilter("linear", "nearest") local utf8 = require("utf8") class = require "libs/middleclass" Timer = require "libs/hump/timer" sfxr = require "libs/sfxrlua/sfxr" require "libs/TSerial" require "_debug_helpers" require "_helpers" require "input" require "color" require "room" require "world" require "world_seed" require "shaders" require "level_editor/editor" -- Ink dialogue: ship the pre-parsed book (LÖVE has no lpeg to parse .ink at -- runtime; the narrator parser degrades to false without it, runtime is fine). -- Use LOVE's require path (rooted at the game dir) so this resolves no matter -- what the shell's CWD is; plain package.path is CWD-relative and breaks when -- launched as `love /path/to/game` from elsewhere. love.filesystem.setRequirePath(love.filesystem.getRequirePath() .. ";libs/narrator/?.lua") narrator = require "narrator.narrator" startSignBook = require "stories.start_sign" function love.load() globalAssetProperties = {} -- rebuilt from the filesystem (art/ + .meta) by getAllAssets _initDefaults() _initGridVars() _initSounds() _initGlobalColors() _initAccessibility() gameWorld = World:new() _initLevelEditor() _initCanvases() inputTimer = Timer.new() -- Resume the game: prefer the runtime autosave so the last saved position is -- restored, falling back to the authored source level. Editor room-opens go -- through their own (source-only) load path, so this only affects resume. currentRoom = Room:new{ name = gameWorld.lastRoom or "start", runtime = true } gameWorld:setLastRoom(currentRoom.name) setEditorRoomName(currentRoom.name) response = "" end -- Autosave on close so a position reached by moving within a room (short of a -- room-to-room transition, which already autosaves in nextRoom) survives. function love.quit() -- An explicit editor save/clear removes the runtime copy intentionally; -- don't immediately recreate it while shutting down. if currentRoom and not suppressRuntimeAutosave then currentRoom:save() if gameWorld then gameWorld:setLastRoom(currentRoom.name) end end end function love.update(dt) if editorView == "world" then Input:stopMoveRepeat() else local repeatedMove = Input:handler(dt) if repeatedMove and not Dialogue.isOpen() then currentRoom:movePlayer(repeatedMove) end end currentRoom:update(dt) -- if love.keyboard.isDown("w") then worldSha = worldSha + 1 end -- if love.keyboard.isDown("s") then worldSha = worldSha - 1 end end function love.draw() love.graphics.setCanvas(gameCanvas) love.graphics.clear() love.graphics.setBlendMode("screen") if editorView == "world" then gameWorld:draw() else currentRoom:draw() end love.graphics.setCanvas(finalCanvas) love.graphics.clear() local scaleCoefficient = .93 local samples = 7 love.graphics.push() for i = 1, samples do local c = 65 / 255 / (i * 1.75) love.graphics.setColor(c, c, c) love.graphics.scale(scaleCoefficient, scaleCoefficient) love.graphics.translate((width / 2 - (width * scaleCoefficient) / 2) + 2, (width / 2 - (height * scaleCoefficient) / 2) + 2) love.graphics.draw(gameCanvas, 0, 0) end love.graphics.pop() love.graphics.setColor(1, 1, 1) love.graphics.draw(gameCanvas, 0, 0) love.graphics.setCanvas() -- Render the sidebar at native panel resolution: the canvas is panel-sized, -- and editorDraw works in the fixed virtual space, scaled up by a transform. -- Blitting the canvas 1:1 afterwards means no rescaling of the final image -- (crisp, nearest-neighbor throughout — nothing is averaged). love.graphics.setCanvas(sideBarCanvas) love.graphics.clear() love.graphics.push() love.graphics.scale(editorRenderScale, editorRenderScale) editorDraw() love.graphics.pop() love.graphics.setCanvas() -- Collect every visible layer before the accessibility pass. Applying the -- shader here also covers the editor sidebar, FPS display, and dialogue, -- rather than only recolouring the room sprites. love.graphics.setCanvas(presentationCanvas) love.graphics.clear() love.graphics.setBlendMode("alpha") love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(finalCanvas, 0, 0) love.graphics.draw(sideBarCanvas, sideBarBox.x, sideBarBox.y) love.graphics.print("Current FPS: "..tostring(love.timer.getFPS( )), 10, 10) Dialogue.draw() love.graphics.setCanvas() love.graphics.setColor(1, 1, 1, 1) local filter = accessibilityModes[accessibilityModeIndex] if filter.shader then filter.shader:send("accessibilityTime", love.timer.getTime()) love.graphics.setShader(filter.shader) end love.graphics.draw(presentationCanvas, 0, 0) love.graphics.setShader() if accessibilityMessage and love.timer.getTime() < accessibilityMessageExpires then love.graphics.setColor(1, 1, 1, 1) love.graphics.printf(accessibilityMessage, 0, 30, love.graphics.getWidth(), "center") end end function love.keypressed(key) -- Available even while a dialogue is open, so the player never has to -- dismiss something important just to change the colour treatment. if key == "f4" then cycleAccessibilityMode() return end -- Talking to a sign captures input: navigate/choose, don't move the player. if Dialogue.isOpen() then Dialogue.keypressed(key) return end if editorView ~= "world" and Input.isPlayerMove(key) then Input:beginMove(key) currentRoom:movePlayer(key) elseif editorView ~= "world" and Input.isPlayerSwitch(key) then local c = Input.playerColor(key) currentRoom:switchPlayerColor(c) end -- if key == "w" then -- worldSha = worldSha + 1 -- end -- if key == "s" then -- worldSha = worldSha - 1 -- end if key == "return" then response = "" editorCompleteResponse() elseif key == "escape" and editorCancelResponse() then return elseif key == "backspace" then local byteoffset = utf8.offset(response, -1) if byteoffset then -- remove the last UTF-8 character. -- string.sub operates on bytes rather than UTF-8 characters, so we couldn't do string.sub(response, 1, -2). response = string.sub(response, 1, byteoffset - 1) editorTextHandler(response) end end end function love.mousepressed(x, y, button) editorMouseHandler(x, y, button) end function love.mousemoved(x, y) editorMouseMoved(x, y) end function love.mousereleased(x, y, button) editorMouseReleased(x, y, button) end function love.textinput(t) response = response .. t editorTextHandler(response) end function love.wheelmoved(dx, dy) -- Scroll the editor's asset/folder list when the cursor is over the sidebar. if editorView == "room" and love.mouse.getX() > width then editorScrollRoomList(dy) end end function love.resize(w, h) _computeDimensions() drawScale = width / gridWidth / 16 _initCanvases() end function _initDefaults() --set filter love.graphics.setDefaultFilter("linear", "nearest") love.filesystem.createDirectory("rooms") --set global width / height / sidebar variables _computeDimensions() --set randomseed math.randomseed(os.time()) --font font = love.graphics.newFont("font/LCD_Solid.ttf", 16) love.graphics.setFont(font) end -- Derive the square play area (side = smaller window dimension) and the -- sidebar that fills the remaining horizontal space. Safe to call any time -- the window size changes. function _computeDimensions() width = love.graphics.getWidth() height = love.graphics.getHeight() if width > height then width = height else height = width end sideBarBox = {x = width, y = 0, w = love.graphics.getWidth() - width, h = height} -- The editor sidebar is authored in a fixed virtual space and scaled up to -- fill the real panel, so every element keeps the same size relative to the -- panel at any window size (and fonts scale with it). Width is constant; -- height follows the real panel's aspect ratio so the upscale is uniform -- (no stretching). `editorScale` is the constant sprite scale in that space. editorScale = 3 local virtualW = 600 local aspect = sideBarBox.w > 0 and (sideBarBox.h / sideBarBox.w) or 2 sb = {w = virtualW, h = virtualW * aspect} -- Factor that maps the virtual layout space onto real panel pixels. The -- sidebar is rasterized at this density (native res), so it stays crisp at -- any window size instead of scaling a fixed low-res canvas. editorRenderScale = sideBarBox.w > 0 and (sideBarBox.w / sb.w) or 1 end -- (Re)create the render targets at the current window size. function _initCanvases() gameCanvas = love.graphics.newCanvas() finalCanvas = love.graphics.newCanvas() presentationCanvas = love.graphics.newCanvas() sideBarCanvas = love.graphics.newCanvas(math.max(1, sideBarBox.w), math.max(1, sideBarBox.h)) sideBarCanvas:setFilter("nearest", "nearest") -- never average when sampled end function _initGridVars() gridWidth, gridHeight = 11, 11 drawScale = width / gridWidth / 16 worldSha = 1 end function _initGlobalColors() gColor = {} gColor.red, gColor.green, gColor.blue, gColor.white = Color:new("red"), Color:new("green"), Color:new("blue"), Color:new("white") end function _initAccessibility() accessibilityModes = { { id = "normal", label = "Normal", shader = nil }, { id = "deuteranopia", label = "Deuteranopia", shader = accessibilityColorShader }, } accessibilityModeIndex = 1 local savedMode = love.filesystem.read("accessibility_mode.txt") if savedMode then savedMode = savedMode:match("[%w_]+") if savedMode == "deuteranopia_high_contrast" then savedMode = "deuteranopia" end for index, mode in ipairs(accessibilityModes) do if mode.id == savedMode then accessibilityModeIndex = index break end end end local mode = accessibilityModes[accessibilityModeIndex] accessibilityMessage = "Color filter: " .. mode.label .. " (F4 to change)" accessibilityMessageExpires = love.timer.getTime() + 4 end function cycleAccessibilityMode() accessibilityModeIndex = accessibilityModeIndex % #accessibilityModes + 1 local mode = accessibilityModes[accessibilityModeIndex] love.filesystem.write("accessibility_mode.txt", mode.id) accessibilityMessage = "Color filter: " .. mode.label .. " (F4 to change)" accessibilityMessageExpires = love.timer.getTime() + 2.5 end function _initSounds() staticWave = sfxr.newSound() staticWave.waveform = sfxr.WAVEFORM.NOISE -- was `wavetype = 3`; field renamed in this sfxr staticWave.frequency.start = .1 staticWave.envelope.release = 0 staticWave.envelope.sustain = 1 staticWave.envelope.decay = 0 static = love.audio.newSource((staticWave:generateSoundData())) static:setLooping(true) static:setVolume(.04) static:setFilter({type = "lowpass", highgain = .25}) -- roll off highs + upper-mids static:play() end