idk even anymore

This commit is contained in:
Your Name 2026-08-07 01:39:47 -04:00
parent 7fa9171ba1
commit febaaaacc7
13 changed files with 2239 additions and 1508 deletions

View file

@ -32,6 +32,41 @@ function writeToSource(relPath, contents)
return love.filesystem.write(relPath, contents)
end
-- The editor occasionally needs to rename a source level after it has written
-- the replacement. Keep that operation alongside writeToSource so packaged
-- games still fall back to LÖVE's writable save directory.
function removeFromSource(relPath)
local base = love.filesystem.getSource()
local path = base .. "/" .. relPath
local file = io.open(path, "r")
if file then
file:close()
return os.remove(path)
end
return love.filesystem.remove(relPath)
end
-- Runtime saves live in LÖVE's write directory. This deliberately uses only
-- love.filesystem operations: they cannot delete files from the source tree.
function clearRuntimeSaveData()
local removed = 0
local function removeTree(path)
if love.filesystem.isDirectory(path) then
for _, child in ipairs(love.filesystem.getDirectoryItems(path)) do
removeTree(path .. "/" .. child)
end
end
if love.filesystem.remove(path) then removed = removed + 1 end
end
-- These are the only files this game writes through love.filesystem: room
-- autosaves and the generated asset cache. Source levels are read-only to
-- this API and cannot be removed here.
removeTree("rooms")
if love.filesystem.remove("assets.prop") then removed = removed + 1 end
return removed
end
-- Read from the project tree directly so a stale copy in LÖVE's save directory
-- can't shadow the real source file. Falls back to love.filesystem for a
-- packaged .love.

BIN
art/_ui/clear_saves.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 B

5
art/_ui/clear_saves.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="#ff6b4a" d="M5 1h6l1 2h3v2H1V3h3zm-1 5h8l-.6 9H4.6z"/>
<path fill="#27110f" d="M6 8h1v5H6zm3 0h1v5H9z"/>
<path fill="#fff3df" d="M2 12.6 3.4 14 8 9.4 12.6 14l1.4-1.4L9.4 8 14 3.4 12.6 2 8 6.6 3.4 2 2 3.4 6.6 8z"/>
</svg>

After

Width:  |  Height:  |  Size: 323 B

View file

@ -9,6 +9,7 @@ local forwardSprite = love.graphics.newImage("art/_ui/forward.png")
local folderSprite = love.graphics.newImage("art/_ui/folder.png")
local cancelSprite = love.graphics.newImage("art/_ui/cancel.png")
local confirmSprite = love.graphics.newImage("art/_ui/confirm.png")
local clearSavesSprite = love.graphics.newImage("art/_ui/clear_saves.png")
local selectedFolder = nil
local selectedAsset = nil
@ -28,10 +29,150 @@ local assetButtons = { {} } --table for rows so we can do stuff
local editingAssetButtons = {}
local labels = {}
local editorRoomName = nil
local roomNameField = nil
local editorStatus = nil
local clearSaveConfirmationExpires = 0
local worldSeedConfirmationExpires = 0
local worldMode = "place"
local worldRoomButtons = {}
local worldControls = {}
local worldPage = 1
local worldPageSize = 5
local function buttonContains(button, x, y)
return x > button.x and x < button.x + button.w and y > button.y and y < button.y + button.h
end
local function rebuildWorldRoomButtons()
worldRoomButtons = {}
local names = gameWorld:listRoomNames()
local pages = math.max(1, math.ceil(#names / worldPageSize))
worldPage = constrain(worldPage, 1, pages)
local first = (worldPage - 1) * worldPageSize + 1
for index = first, math.min(first + worldPageSize - 1, #names) do
local roomName = names[index]
local row = index - first
table.insert(worldRoomButtons, { name = roomName, x = 10, y = 36 + row * 68, w = sideBarBox.w - 20, h = 60 })
end
worldControls = {
{ text = worldMode == "place" and "MODE: PLACE ROOMS" or "MODE: OPEN LEVELS", x = 10, y = sideBarBox.h - 194, w = sideBarBox.w - 20, h = 34,
onClic = function() worldMode = worldMode == "place" and "jump" or "place"; rebuildWorldRoomButtons() end },
{ text = "RESEED OLD EXITS", x = 10, y = sideBarBox.h - 152, w = sideBarBox.w - 20, h = 34,
onClic = function()
local now = love.timer.getTime()
if now > worldSeedConfirmationExpires then
worldSeedConfirmationExpires = now + 3
editorStatus = "Click RESEED OLD EXITS again within 3 seconds to replace the current map."
return
end
worldSeedConfirmationExpires = 0
local result = seedWorldFromLegacyExits(gameWorld)
editorStatus = "Seeded " .. result.placed .. " rooms; " .. result.links .. " legacy links (" .. result.conflicts .. " conflicts kept separate)."
rebuildWorldRoomButtons()
end },
{ text = "< PAGE " .. worldPage .. "/" .. pages .. " >", x = 10, y = sideBarBox.h - 110, w = sideBarBox.w - 20, h = 34,
onClic = function(buttonX)
if buttonX < sideBarBox.w / 2 then worldPage = math.max(1, worldPage - 1) else worldPage = math.min(pages, worldPage + 1) end
rebuildWorldRoomButtons()
end },
}
end
local function drawWorldSidebar()
love.graphics.setColor(gColor.white:set())
love.graphics.printf("ROOMS — " .. (worldMode == "place" and "select to place; re-click to clear" or "click a map room to open"), 8, 10, sideBarBox.w - 16, "center")
for _, button in ipairs(worldRoomButtons) do
love.graphics.setColor(button.name == selectedRoom and 255 or 100, button.name == selectedRoom and 255 or 100, button.name == selectedRoom and 255 or 100, 255)
love.graphics.rectangle("line", button.x, button.y, button.w, button.h)
gameWorld:drawRoomPreview(button.name, button.x + 4, button.y + 4, button.h - 8)
love.graphics.setColor(gColor.white:set())
love.graphics.printf(button.name, button.x + button.h + 4, button.y + 21, button.w - button.h - 8, "left")
end
for _, control in ipairs(worldControls) do
love.graphics.setColor(255, 255, 255, 255)
love.graphics.rectangle("line", control.x, control.y, control.w, control.h)
love.graphics.printf(control.text, control.x, control.y + 9, control.w, "center")
end
uiButtons.world:draw()
end
local function validRoomName(name)
name = (name or ""):match("^%s*(.-)%s*$")
if name == "" then return nil, "Enter a level name." end
if not name:match("^[%w][%w_-]*$") then
return nil, "Use letters, numbers, _ or - for level names."
end
return name
end
local function beginRoomNameEdit()
activeField = "roomName"
inputText = ""
response = ""
roomNameField:editText("")
editorStatus = "Type a name, press Enter, then Save."
end
local function saveRoomFromEditor()
local requestedName = editorRoomName or currentRoom.name
if activeField == "roomName" then requestedName = inputText end
local name, err = validRoomName(requestedName)
if not name then
editorStatus = err
return
end
local oldName = currentRoom.name
if name ~= oldName and readFromSource("rooms/" .. name .. ".sav") then
editorStatus = "A source level named '" .. name .. "' already exists."
return
end
if not currentRoom:saveToSource(name) then
editorStatus = "Could not save '" .. name .. "'."
return
end
-- A changed name is a rename, not an accidental duplicate. Do this only
-- after the new source file is safely written.
if oldName and oldName ~= name then
removeFromSource("rooms/" .. oldName .. ".sav")
gameWorld:renameRoom(oldName, name)
end
gameWorld:invalidatePreview(name)
if editorView == "world" then rebuildWorldRoomButtons() end
currentRoom.name = name
editorRoomName = name
roomNameField:editText(name)
activeField = nil
editorStatus = "Saved source level '" .. name .. ".'"
end
local function clearRuntimeSaves()
local now = love.timer.getTime()
if now > clearSaveConfirmationExpires then
clearSaveConfirmationExpires = now + 3
editorStatus = "Click the clear-save icon again within 3 seconds to erase runtime saves."
return
end
local removed = clearRuntimeSaveData()
clearSaveConfirmationExpires = 0
local roomName = currentRoom.name
if roomName then
currentRoom = Room:new{ name = roomName }
setEditorRoomName(currentRoom.name)
editorStatus = "Cleared " .. removed .. " runtime save item(s) and reloaded '" .. roomName .. ".'"
else
editorStatus = "Cleared " .. removed .. " runtime save item(s). No named level to reload."
end
end
function _initLevelEditor()
gameAssets = getAllAssets()
editorRoomName = ""
--create folder buttons
local counter = 1
@ -70,8 +211,7 @@ function _initLevelEditor()
onClic = {
left = function()
if editorView == "room" then
print("saveing!!")
currentRoom:save()
saveRoomFromEditor()
end
end,
right = function()
@ -87,11 +227,7 @@ function _initLevelEditor()
sprite = love.graphics.newImage("art/_ui/world.png"),
onClic = {
left = function()
print(editorView)
if editorView == "room" then
print("saveing!!")
currentRoom:save()
end
if editorView == "world" then assetsView() else worldView() end
end,
right = function()
@ -99,6 +235,17 @@ function _initLevelEditor()
},
x = sideBarBox.w * (3 / 4),
y = sideBarBox.h * (9 / 10)
}, "world")
_addUIButton(createUIElement{
name = "clearSaves",
sprite = clearSavesSprite,
onClic = {
left = clearRuntimeSaves,
},
x = sideBarBox.w / 2,
y = sideBarBox.h * (9 / 10),
scale = drawScale / 3
})
for _, color in ipairs({"white", "red", "green", "blue"}) do
@ -120,6 +267,22 @@ function _initLevelEditor()
})
end
roomNameField = createUIElement{
name = "roomName",
text = editorRoomName,
x = 25,
y = sideBarBox.h * (7 / 10),
onClic = {
left = beginRoomNameEdit,
}
}
_addUIButton(roomNameField, "roomName")
end
function setEditorRoomName(name)
editorRoomName = name or ""
if roomNameField then roomNameField:editText(editorRoomName) end
end
function setSelectedColor(color)
@ -325,7 +488,41 @@ function assetsView()
end
function worldView()
editorView = "world"
activeField = nil
selectedFolder = nil
worldMode = "place"
worldPage = 1
rebuildWorldRoomButtons()
end
local function newWorldRoomName(cellX, cellY)
local base = string.format("room_%02d_%02d", cellX, cellY)
local name, suffix = base, 2
while gameWorld:roomLocation(name) or readFromSource("rooms/" .. name .. ".sav") do
name = base .. "_" .. suffix
suffix = suffix + 1
end
return name
end
local function openWorldRoom(cellX, cellY)
local name = gameWorld:roomAt(cellX, cellY)
if not name then
name = newWorldRoomName(cellX, cellY)
currentRoom = Room:new{ name = name }
-- Creating from an empty cell is a real level creation, not merely a
-- transient map reference, so it immediately joins the room picker.
currentRoom:saveToSource(name)
gameWorld:placeRoom(name, cellX, cellY)
gameWorld:invalidatePreview(name)
editorStatus = "Created and opened '" .. name .. ".'"
else
currentRoom = Room:new{ name = name }
editorStatus = "Editing '" .. name .. ".'"
end
setEditorRoomName(currentRoom.name)
assetsView()
end
function editorMouseHandler(x, y, button)
@ -361,11 +558,43 @@ function editorMouseHandler(x, y, button)
uiElement:onClic(button)
end
end
elseif editorView == "world" then
for _, roomButton in ipairs(worldRoomButtons) do
if buttonContains(roomButton, x, y) then
if selectedRoom == roomButton.name then
selectedRoom = nil
editorStatus = "Room brush cleared. Click the map to open or create a level."
else
selectedRoom = roomButton.name
editorStatus = "Selected '" .. selectedRoom .. "' for placement. Click it again to clear."
end
return
end
end
for _, control in ipairs(worldControls) do
if buttonContains(control, x, y) then
control:onClic(x)
return
end
end
if uiButtons.world:clicIsInBox(x, y) then uiButtons.world:onClic(button) end
end
else
-- click landed on the grid (game area): place the selected asset
if editorView == "room" and button == 1 and selectedAsset then
currentRoom:attemptPlace(selectedAsset, gameAssets[selectedAsset], x, y, selectedColor)
elseif editorView == "world" then
local cellX, cellY = gameWorld:getCellFromMousePos(x, y)
if not gameWorld:isInBounds(cellX, cellY) then return end
if button == 2 then
local removed, name = gameWorld:removeRoom(cellX, cellY)
if removed then editorStatus = "Removed '" .. name .. "' from the world." end
elseif worldMode == "jump" or not selectedRoom then
openWorldRoom(cellX, cellY)
elseif selectedRoom then
gameWorld:placeRoom(selectedRoom, cellX, cellY)
editorStatus = "Placed '" .. selectedRoom .. "' at " .. cellX .. ", " .. cellY .. "."
end
end
end
end
@ -374,15 +603,18 @@ function editorTextHandler(text)
if editorView == "asset" and activeField then
inputText = text
editingAssetButtons[activeField]:editText(text)
elseif editorView == "room" and activeField == "roomName" then
inputText = text
roomNameField:editText(text)
end
end
function editorCompleteResponse()
if editorView == "asset" then
if editorView == "asset" and activeField then
assetProperties[activeField] = inputText
print(assetProperties[activeField], "BLELERLLLRLRLRR")
elseif editorView == "room" and activeField == "roomName" then
editorRoomName = inputText
end
print(activeField, assetProperties[activeField], "ass")
activeField = nil
end
@ -396,6 +628,8 @@ end
function editorDraw()
if editorView == "room" then
love.graphics.setColor(gColor.white:set())
love.graphics.printf("LEVEL NAME", 0, sideBarBox.h * (7 / 10) - 24, sideBarBox.w, "center")
for _, button in pairs(uiButtons) do
button:draw()
end
@ -412,9 +646,15 @@ function editorDraw()
for _, button in pairs(editingAssetButtons) do
button:draw()
end
elseif editorView == "world" then
drawWorldSidebar()
end
for _, label in pairs(labels) do
label:draw()
end
if editorStatus then
love.graphics.setColor(gColor.white:set())
love.graphics.printf(editorStatus, 10, sideBarBox.h * (6 / 10), sideBarBox.w - 20, "center")
end
end

View file

@ -26,7 +26,7 @@ function GUI:initialize(t)
if t.sprite then
print(self.name)
self.scale = drawScale * 2 / 3
self.scale = t.scale or drawScale * 2 / 3
self.w = t.sprite:getWidth() * self.scale
self.h = t.sprite:getHeight() * self.scale
self.sprite = t.sprite
@ -126,4 +126,4 @@ end
function GUI:sha()
return math.random(-worldSha, worldSha) * .5
end
end

View file

@ -10,6 +10,8 @@ require "_helpers"
require "input"
require "color"
require "room"
require "world"
require "world_seed"
require "shaders"
require "level_editor/editor"
@ -20,6 +22,7 @@ function love.load()
_initGridVars()
_initSounds()
_initGlobalColors()
gameWorld = World:new()
_initLevelEditor()
gameCanvas = love.graphics.newCanvas()
@ -32,6 +35,7 @@ function love.load()
currentRoom = Room:new{
name = "start" -- TEMP: load a migrated level to eyeball it; revert once the room picker lands
}
setEditorRoomName(currentRoom.name)
response = ""
end
@ -48,7 +52,11 @@ function love.draw()
love.graphics.setCanvas(gameCanvas)
love.graphics.clear()
love.graphics.setBlendMode("screen")
currentRoom:draw()
if editorView == "world" then
gameWorld:draw()
else
currentRoom:draw()
end
love.graphics.setCanvas(finalCanvas)
love.graphics.clear()
@ -85,9 +93,9 @@ function love.draw()
end
function love.keypressed(key)
if Input.isPlayerMove(key) then
if editorView ~= "world" and Input.isPlayerMove(key) then
currentRoom:movePlayer(key)
elseif Input.isPlayerSwitch(key) then
elseif editorView ~= "world" and Input.isPlayerSwitch(key) then
local c = Input.playerColor(key)
currentRoom:switchPlayerColor(c)
end
@ -115,7 +123,7 @@ function love.keypressed(key)
end
function love.mousepressed(x, y, button)
if button == 2 then
if editorView == "room" and button == 2 then
currentRoom:attemptDelete(x, y, selectedColor)
end
editorMouseHandler(x, y, button)
@ -175,4 +183,3 @@ function _initSounds()
static:setVolume(.15)
static:play()
end

View file

@ -423,6 +423,10 @@ function Room:tick()
for _, door in pairs(self.doors) do
door:setColors(setDoorColors)
end
-- Door:setColors can change collision from unmoveable to none. Rebuild once
-- more after that state change so an open-looking door is passable on the
-- very next input, rather than leaving the previous tick's solid cell here.
self:createCollideablesMatrix()
self:nextRoomCheck()
end
@ -653,7 +657,10 @@ function Room:attemptPlace(name, props, mouseX, mouseY, color)
end
function Room:load(name)
local room = readFromSource("rooms/" .. name .. ".sav")
-- Runtime rooms are game state: let LÖVE resolve them from its save
-- directory, where Room:save writes autosaves. Source files remain the
-- shipped/editor versions until the editor explicitly saves one.
local room = love.filesystem.read("rooms/" .. name .. ".sav")
if not room then
-- no save yet: start with a blank room to build in the editor
print("no save for '" .. name .. "', starting blank")
@ -672,10 +679,19 @@ function Room:save()
local name = self.name or "test"
local room = TSerial.pack(self:serialize(), nil, true)
local success = writeToSource("rooms/" .. name .. ".sav", room)
local success = love.filesystem.write("rooms/" .. name .. ".sav", room)
if not success then error "great job u fool" end
end
-- This is intentionally separate from Room:save. It is the editor's explicit
-- "write this level into the project" action; gameplay autosaves must never
-- call it.
function Room:saveToSource(name)
name = name or self.name or "test"
local room = TSerial.pack(self:serialize(), nil, true)
return writeToSource("rooms/" .. name .. ".sav", room)
end
function Room:serialize()
local objects = {}
@ -734,14 +750,18 @@ function nextRoom(pos)
newPos.y = gridHeight
end
local currentName = currentRoom.name
local nextName = gameWorld and currentName and gameWorld:getNeighbor(currentName, exit)
currentRoom:save()
currentRoom = Room:new{
-- name = "test",
-- Missing map neighbours intentionally wrap back into the room just exited.
-- A placed neighbour gets the same carried player state at its opposite edge.
currentRoom = Room:new{
name = nextName or currentName,
player = {
x = newPos.x, y = newPos.y, activeColors = currentRoom:getActiveColors()
}
}
if currentRoom.name then setEditorRoomName(currentRoom.name) end
end

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

146
rooms/world.sav Normal file
View file

@ -0,0 +1,146 @@
{
width=18,
height=18,
rooms={
{
y=11,
x=9,
name="start"
},
{
y=10,
x=10,
name="3"
},
{
y=10,
x=11,
name="4"
},
{
y=10,
x=12,
name="5"
},
{
y=9,
x=12,
name="village_1"
},
{
y=8,
x=12,
name="village_2"
},
{
y=8,
x=13,
name="village_3"
},
{
y=8,
x=11,
name="village_4"
},
{
y=7,
x=11,
name="yellow_1"
},
{
y=6,
x=11,
name="yellow_3"
},
{
y=7,
x=12,
name="yellow_2"
},
{
y=5,
x=11,
name="yellow_4"
},
{
y=4,
x=11,
name="yellow_5"
},
{
y=5,
x=10,
name="yellow_6"
},
{
y=1,
x=1,
name="default"
},
{
y=1,
x=3,
name="old_yellow_3"
},
{
y=1,
x=5,
name="test_patt"
},
{
y=1,
x=7,
name="test_patt_update"
},
{
y=1,
x=9,
name="white_asymmetrical"
},
{
y=1,
x=11,
name="white_ok"
},
{
y=1,
x=13,
name="white_primary"
},
{
y=1,
x=15,
name="yellow_arches"
},
{
y=1,
x=17,
name="yellow_asymmetrical"
},
{
y=3,
x=1,
name="yellow_cc_moustache"
},
{
y=3,
x=3,
name="yellow_color_changing_levvel_1"
},
{
y=3,
x=5,
name="yellow_skull"
},
{
y=3,
x=7,
name="yellow_smile"
},
{
y=10,
x=9,
name="2"
}
}
}

View file

@ -0,0 +1,43 @@
-- Convert a pre-flat-schema room save into the current { objects = { ... } }
-- format. Usage:
-- lua tools/migrate_legacy_room.lua legacy.sav migrated.sav
local inputPath, outputPath = arg[1], arg[2]
assert(inputPath and outputPath, "usage: lua tools/migrate_legacy_room.lua legacy.sav migrated.sav")
local input = assert(io.open(inputPath, "r"))
local chunk, err = load("return " .. input:read("*a"))
input:close()
assert(chunk, err)
local legacy = chunk()
local objects = {}
local function append(class, object, includeColor)
local converted = {
class = class,
x = object.x,
y = object.y,
}
if includeColor ~= false then converted.color = object.color end
table.insert(objects, converted)
end
for _, color in ipairs({ "red", "green", "blue" }) do
for _, object in ipairs(legacy.players[color] or {}) do append("player", object) end
-- Doors are shared between color layers in the current schema, so they have
-- no color field when serialized.
for _, object in ipairs(legacy.doors[color] or {}) do append("door", object, false) end
for _, object in ipairs(legacy.static_walls[color] or {}) do append("static_wall", object) end
-- The legacy wall sprite was renamed to box when assets became data-driven.
for _, object in ipairs(legacy.walls[color] or {}) do append("box", object) end
for _, object in ipairs(legacy.switches[color] or {}) do append("switch", object) end
end
for _, sage in pairs(legacy.sages or {}) do
table.insert(objects, { class = "npc", x = sage.x, y = sage.y })
end
require "libs/TSerial"
local output = assert(io.open(outputPath, "w"))
output:write(TSerial.pack({ objects = objects }, nil, true))
output:close()

179
world.lua Normal file
View file

@ -0,0 +1,179 @@
World = class("World")
-- The world is deliberately only a layout: rooms continue to own their puzzle
-- state. Keeping the two separate lets the editor move a room without
-- rewriting its level file.
function World:initialize()
self.width, self.height = 18, 18
self.rooms = {}
self.roomByName = {}
self.previewCache = {}
self:load()
end
function World:listRoomNames()
local names = {}
for _, file in ipairs(love.filesystem.getDirectoryItems("rooms")) do
local name = file:match("^(.+)%.sav$")
if name and name ~= "world" then table.insert(names, name) end
end
table.sort(names)
return names
end
function World:load()
local raw = readFromSource("rooms/world.sav")
if not raw then return end
local ok, save = pcall(TSerial.unpack, raw)
if not ok or type(save) ~= "table" then return end
for _, room in ipairs(save.rooms or {}) do
if room.name and self:isInBounds(room.x, room.y) then
self:_putRoom(room.name, room.x, room.y)
end
end
end
function World:save()
local rooms = {}
for _, room in ipairs(self.rooms) do
table.insert(rooms, { name = room.name, x = room.x, y = room.y })
end
return writeToSource("rooms/world.sav", TSerial.pack({ width = self.width, height = self.height, rooms = rooms }, nil, true))
end
function World:isInBounds(x, y)
return x and y and x >= 1 and x <= self.width and y >= 1 and y <= self.height
end
function World:roomAt(x, y)
for _, room in ipairs(self.rooms) do
if room.x == x and room.y == y then return room.name end
end
return nil
end
function World:roomLocation(name)
return self.roomByName[name]
end
function World:_putRoom(name, x, y)
local existing = self.roomByName[name]
if existing then
for i = #self.rooms, 1, -1 do
if self.rooms[i] == existing then table.remove(self.rooms, i) end
end
end
for i = #self.rooms, 1, -1 do
if self.rooms[i].x == x and self.rooms[i].y == y then
self.roomByName[self.rooms[i].name] = nil
table.remove(self.rooms, i)
end
end
local room = { name = name, x = x, y = y }
table.insert(self.rooms, room)
self.roomByName[name] = room
return room
end
function World:placeRoom(name, x, y)
if not name or not self:isInBounds(x, y) then return false end
self:_putRoom(name, x, y)
return self:save()
end
function World:removeRoom(x, y)
for i = #self.rooms, 1, -1 do
local room = self.rooms[i]
if room.x == x and room.y == y then
self.roomByName[room.name] = nil
table.remove(self.rooms, i)
return self:save(), room.name
end
end
return false
end
function World:renameRoom(oldName, newName)
local room = self:roomLocation(oldName)
if not room or oldName == newName then return false end
self.roomByName[oldName] = nil
room.name = newName
self.roomByName[newName] = room
self:invalidatePreview(oldName)
self:invalidatePreview(newName)
return self:save()
end
function World:getNeighbor(name, exit)
local room = self:roomLocation(name)
if not room then return nil end
local directions = {
left = { x = -1, y = 0 }, right = { x = 1, y = 0 },
top = { x = 0, y = -1 }, bottom = { x = 0, y = 1 },
}
local direction = directions[exit]
if not direction then return nil end
return self:roomAt(room.x + direction.x, room.y + direction.y)
end
function World:getCellFromMousePos(mouseX, mouseY)
return math.floor(mouseX / (width / self.width)) + 1, math.floor(mouseY / (height / self.height)) + 1
end
function World:getRoomData(name)
-- The current room is live: its preview reflects unsaved edits immediately.
if currentRoom and currentRoom.name == name then return currentRoom:serialize() end
if not self.previewCache[name] then
local raw = readFromSource("rooms/" .. name .. ".sav")
if not raw then return nil end
local ok, room = pcall(TSerial.unpack, raw)
if not ok then return nil end
self.previewCache[name] = room
end
return self.previewCache[name]
end
function World:invalidatePreview(name)
self.previewCache[name] = nil
end
function World:drawRoomPreview(name, x, y, size)
local room = self:getRoomData(name)
if not room then return end
local scale = size / (gridWidth * 16)
love.graphics.setScissor(x, y, size, size)
for _, object in ipairs(room.objects or {}) do
if object.class == "npc" then
love.graphics.setColor(255, 255, 255, 255)
love.graphics.print("?", x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, 0, scale, scale)
else
local props = globalAssetProperties[object.class]
if props and props.sprites then
local colors = object.class == "door" and { "red", "green", "blue" } or { object.color }
for _, color in ipairs(colors) do
local path = props.sprites[color]
if path then
love.graphics.setColor(gColor[color]:set())
love.graphics.draw(loadSprite(path), x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, 0, scale, scale)
end
end
end
end
end
love.graphics.setScissor()
end
function World:draw()
local cell = width / self.width
love.graphics.setColor(12, 12, 20, 255)
love.graphics.rectangle("fill", 0, 0, width, height)
for y = 1, self.height do
for x = 1, self.width do
local screenX, screenY = (x - 1) * cell, (y - 1) * cell
local name = self:roomAt(x, y)
if name then self:drawRoomPreview(name, screenX, screenY, cell) end
love.graphics.setColor(name and 255 or 70, name and 255 or 70, name and 255 or 90, 255)
love.graphics.rectangle("line", screenX, screenY, cell, cell)
end
end
end

70
world_seed.lua Normal file
View file

@ -0,0 +1,70 @@
-- Make a first 18x18 layout from the exit links preserved in the legacy saves.
-- Some old test rooms share the same one-way exit, so a grid cannot represent
-- every such link at once. Those rooms are still placed, but isolated, rather
-- than silently overwriting a room that already occupies that neighbour.
function seedWorldFromLegacyExits(world)
local available = {}
for _, name in ipairs(world:listRoomNames()) do available[name] = true end
local legacy = {}
for name in pairs(available) do
local raw = readFromSource("rooms/_pre_migration_backup/" .. name .. ".sav")
if raw then
local ok, save = pcall(TSerial.unpack, raw)
if ok and type(save) == "table" then legacy[name] = save.exits or {} end
end
end
world.rooms, world.roomByName = {}, {}
local root = available.start and "start" or world:listRoomNames()[1]
if not root then return { placed = 0, links = 0, conflicts = 0 } end
world:_putRoom(root, 9, 11)
local queue, queued = { root }, { [root] = true }
local directions = {
top = { x = 0, y = -1 }, right = { x = 1, y = 0 },
bottom = { x = 0, y = 1 }, left = { x = -1, y = 0 },
}
local order = { "top", "right", "bottom", "left" }
local links, conflicts = 0, 0
local index = 1
while index <= #queue do
local name = queue[index]
local source = world:roomLocation(name)
for _, exit in ipairs(order) do
local target = legacy[name] and legacy[name][exit]
target = target and tostring(target)
local direction = directions[exit]
if target and available[target] then
local x, y = source.x + direction.x, source.y + direction.y
local occupied, known = world:roomAt(x, y), world:roomLocation(target)
if world:isInBounds(x, y) and (not occupied or occupied == target) and (not known or (known.x == x and known.y == y)) then
if not known then world:_putRoom(target, x, y) end
links = links + 1
if not queued[target] then table.insert(queue, target); queued[target] = true end
else
conflicts = conflicts + 1
end
end
end
index = index + 1
end
-- Keep every migrated room visible/selectable without creating accidental
-- exits between the disconnected legacy variants.
for _, name in ipairs(world:listRoomNames()) do
if not world:roomLocation(name) then
local placed = false
for y = 1, world.height do
for x = 1, world.width do
if not world:roomAt(x, y) then
local nearby = false
for dy = -1, 1 do for dx = -1, 1 do if world:roomAt(x + dx, y + dy) then nearby = true end end end
if not nearby then world:_putRoom(name, x, y); placed = true; break end
end
end
if placed then break end
end
end
end
world:save()
return { placed = #world.rooms, links = links, conflicts = conflicts }
end