- Mark: a debug non-collidable named destination (pixel-art target icon), editor tool + name modal, globally-unique names, programmatic Room:addMark - NPCs walk to Marks: each RGB copy is a per-room resident (like the player), running its own A* on its own colour layer, transferring rooms as it crosses seams; rendered and collided by the room it currently stands in - Player copies are obstacles in NPC pathfinding - A* is footprint-aware for multi-cell NPCs - Editor: click an NPC to open a filterable destination picker - Ink: Dialogue.open binds walk_to; Jorge can be sent to "end"/"begin" - Fix: a blocked NPC now replans each tick and resumes instead of freezing - [npc] move diagnostics under DEBUG (start/goal/route or block reason) - Docs (adding-npcs) + CLAUDE.md LÖVE 11.x/float-colour corrections - Fix test harness loadSprite stub; movement tests green (28) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
459 lines
15 KiB
Lua
459 lines
15 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 "sound"
|
|
require "room"
|
|
require "world"
|
|
require "world_seed"
|
|
require "shaders"
|
|
require "map_manager"
|
|
GameUI = require "game_ui"
|
|
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"
|
|
|
|
-- DEV/DEBUG mode. When on, the level editor is available and rooms with no
|
|
-- player copy present still render every channel, so whole levels stay visible
|
|
-- instead of a black screen. Turn it off for a player-facing build; both the
|
|
-- editor and that all-channels fallback then disappear. Override at launch with
|
|
-- the DEBUG env var (DEBUG=0 to force off, DEBUG=1 to force on).
|
|
DEBUG = os.getenv("DEBUG") ~= "0"
|
|
|
|
-- Whether editor "note" annotations render over the room. Toggled with Tab (see
|
|
-- love.keypressed); default on. Only ever consulted under DEBUG (see Room:draw).
|
|
NOTES_VISIBLE = true
|
|
|
|
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() and not MapManager:isOpen() then gameWorld:movePlayer(currentRoom, repeatedMove) end
|
|
end
|
|
currentRoom:update(dt)
|
|
if gameWorld then gameWorld:update(dt) end
|
|
MapManager:record(currentRoom)
|
|
|
|
-- 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 MapManager:isOpen() then
|
|
MapManager:draw()
|
|
elseif DEBUG and editorView == "world" then
|
|
gameWorld:draw()
|
|
elseif DEBUG and editorView == "wire" then
|
|
gameWorld:drawWireEditor(wireEditorCamera())
|
|
else
|
|
currentRoom:draw()
|
|
end
|
|
if MapManager:isOpen() then MapManager:drawVenn() else GameUI.drawColorVenn(currentRoom) 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). The editor is a
|
|
-- DEBUG-only surface, so skip the whole panel in a player-facing build.
|
|
if DEBUG then
|
|
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()
|
|
end
|
|
|
|
-- 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)
|
|
GameUI.drawBacking()
|
|
GameUI.drawVennOverlay()
|
|
love.graphics.setBlendMode("alpha")
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
if DEBUG then love.graphics.draw(sideBarCanvas, sideBarBox.x, sideBarBox.y) end
|
|
if DEBUG then editorDrawNoteHover() end
|
|
if DEBUG then editorDrawMarkPicker() end
|
|
if DEBUG then editorDrawMarkNameEditor() end
|
|
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
|
|
|
|
-- The map overlay is modal: `m` toggles it, and while open it swallows keys.
|
|
-- But not while an editor text field is focused, or typing "m" in a search /
|
|
-- name box would open the map instead of entering the character.
|
|
if not (DEBUG and editorTextFieldActive()) then
|
|
if MapManager:keypressed(key) then return end
|
|
end
|
|
|
|
-- The focused note text field owns the keyboard entirely: arrows navigate the
|
|
-- text instead of moving the player, Return inserts a newline, etc.
|
|
if DEBUG and noteEditorActive() then
|
|
if key == "escape" then cancelNoteMode() else noteEditorKeypressed(key) end
|
|
return
|
|
end
|
|
|
|
-- The mark modals (destination picker, name entry) own the keyboard while open:
|
|
-- characters filter/type via love.textinput, Enter confirms, Esc cancels.
|
|
if DEBUG and (markPickerActive() or markNameEditorActive()) then
|
|
if key == "escape" then
|
|
editorCancelResponse()
|
|
elseif key == "return" then
|
|
response = ""
|
|
editorCompleteResponse()
|
|
elseif key == "backspace" then
|
|
local byteoffset = utf8.offset(response, -1)
|
|
if byteoffset then
|
|
response = string.sub(response, 1, byteoffset - 1)
|
|
editorTextHandler(response)
|
|
end
|
|
end
|
|
return
|
|
end
|
|
|
|
-- Tab toggles the editor's note overlay (DEBUG-only; default on).
|
|
if DEBUG and key == "tab" then
|
|
NOTES_VISIBLE = not NOTES_VISIBLE
|
|
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
|
|
|
|
-- Editor text-field controls (room-name entry etc.): DEBUG-only.
|
|
if not DEBUG then return 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
|
|
|
|
-- Every mouse/text callback below only ever drives the editor, so they are
|
|
-- inert outside DEBUG: a player-facing build is keyboard-only.
|
|
function love.mousepressed(x, y, button)
|
|
if not DEBUG then return end
|
|
editorMouseHandler(x, y, button)
|
|
end
|
|
|
|
function love.mousemoved(x, y)
|
|
if not DEBUG then return end
|
|
editorMouseMoved(x, y)
|
|
end
|
|
|
|
function love.mousereleased(x, y, button)
|
|
if not DEBUG then return end
|
|
editorMouseReleased(x, y, button)
|
|
end
|
|
|
|
function love.textinput(t)
|
|
if not DEBUG then return end
|
|
if noteEditorActive() then
|
|
noteEditorTextinput(t)
|
|
return
|
|
end
|
|
response = response .. t
|
|
editorTextHandler(response)
|
|
end
|
|
|
|
function love.wheelmoved(dx, dy)
|
|
if not DEBUG then return end
|
|
-- Scroll the editor's asset/folder list when the cursor is over the sidebar.
|
|
if editorView == "room" and love.mouse.getX() >= sideBarBox.x 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
|
|
|
|
local availableWidth = love.graphics.getWidth() - width
|
|
local hudWidth = math.min(availableWidth, width / (gridWidth or 11) * 2)
|
|
gameUiBox = {x = width, y = 0, w = hudWidth, h = height}
|
|
sideBarBox = {x = gameUiBox.x + gameUiBox.w, y = 0, w = availableWidth - gameUiBox.w, 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
|
|
GameUI.init()
|
|
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()
|
|
Sound.init()
|
|
|
|
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
|