chroma_solstice/main.lua
Your Name c348b81a93 Add named Marks and cross-room NPC pathfinding
- 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>
2026-08-21 15:56:53 -04:00

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