chroma_solstice/main.lua
2026-08-12 09:00:44 -04:00

372 lines
12 KiB
Lua

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 "profiler"
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"
jorgeBook = require "stories.jorge"
function love.load()
globalAssetProperties = {} -- rebuilt from the filesystem (art/ + .meta) by getAllAssets
_initDefaults()
_initGridVars()
_initSounds()
_initGlobalColors()
_initAccessibility()
gameWorld = World:new()
_initLevelEditor()
-- getAllAssets runs in _initLevelEditor; deserialize the world only after
-- its asset metadata exists, otherwise every non-player object is skipped.
gameWorld:loadLevels()
_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.
local initialRoom = gameWorld.lastRoom or "start"
-- Preserve the old resume behavior for the active room while the rest of the
-- world comes from the authored level files.
currentRoom = Room:new{name = initialRoom, runtime = true}
if gameWorld.levels[initialRoom] then
gameWorld.levels[initialRoom] = currentRoom
gameWorld:rebuildCollision()
gameWorld:resolveCircuits()
gameWorld:rebuildCollision()
end
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
if gameWorld then
gameWorld:markDirty(currentRoom)
gameWorld:flushRuntime()
else
currentRoom:save()
end
end
end
function love.update(dt)
if editorView == "world" or editorView == "wire" then
Input:stopMoveRepeat()
else
local repeatedMove = Input:handler(dt)
if repeatedMove and not Dialogue.isOpen() then gameWorld:movePlayer(currentRoom, 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()
elseif editorView == "wire" then
gameWorld:drawWireEditor(wireEditorCamera())
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)
-- The gameplay view now intentionally extends beyond its old room bounds.
-- Keep the editor panel opaque so that neighbourhood preview never shows
-- through empty areas of the UI.
love.graphics.clear(0, 0, 0, 1)
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)
PerfProfiler:draw()
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)
-- Developer CPU sampling: record a representative moving sequence, then
-- press F9 again to write folded stacks for flamegraph.pl.
if key == "f9" then
PerfProfiler:toggle()
return
end
-- 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 == "wire" and editorWireKeypressed(key) then return end
if editorView ~= "world" and editorView ~= "wire" and Input.isPlayerMove(key) then
Input:beginMove(key)
gameWorld:movePlayer(currentRoom, key)
elseif editorView ~= "world" and editorView ~= "wire" and Input.isPlayerSwitch(key) then
local c = Input.playerColor(key)
gameWorld:switchPlayerColor(currentRoom, c)
elseif editorView ~= "world" and editorView ~= "wire" and gameWorld:browseRoom(currentRoom, key) then
return
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()
-- Reused for each cardinal neighbour's alternate real-colour view at the
-- porous room boundary. Full-screen dimensions keep it pixel-aligned with
-- the gameplay canvas during shader compositing.
porousRoomCanvas = 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