Compare commits

...

10 commits

Author SHA1 Message Date
Your Name
e73f63b5d4 add dialogue consequences design notes
next-steps plan for cross-conversation dialogue state: WorldState as the
serialized source of truth, Ink books as disposable views.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 12:01:50 -04:00
Your Name
5413823b26 add dialog 2026-08-08 00:09:50 -04:00
Your Name
c92c0f6ff7 fix state thigns and port to love11 2026-08-07 20:56:55 -04:00
Your Name
ff5abf6bcd trim the main boxes to not overlap the X wall edges, moer distinct 2026-08-07 18:20:01 -04:00
Your Name
2e0013ec17 fix switch scaling i guess? 2026-08-07 18:01:36 -04:00
Your Name
1850737cf6 sav fixes 2026-08-07 17:49:13 -04:00
Your Name
12c37b5fa9 better shake 2026-08-07 10:27:56 -04:00
Your Name
0f79fe99ae editor search 2026-08-07 09:08:34 -04:00
Your Name
b9b0726506 misc 2026-08-07 03:22:28 -04:00
Your Name
f8e3d78332 move repeatedly in direction 2026-08-07 02:01:39 -04:00
44 changed files with 4530 additions and 3864 deletions

View file

@ -48,10 +48,18 @@ end
-- Runtime saves live in LÖVE's write directory. This deliberately uses only -- Runtime saves live in LÖVE's write directory. This deliberately uses only
-- love.filesystem operations: they cannot delete files from the source tree. -- love.filesystem operations: they cannot delete files from the source tree.
function clearRuntimeSaveData() function clearRuntimeSaveData(roomName)
local removed = 0 local removed = 0
if roomName then
-- love.filesystem.remove only affects the writable save directory, never
-- the shipped/source room with the same path.
if love.filesystem.remove("rooms/" .. roomName .. ".sav") then removed = 1 end
return removed
end
local function removeTree(path) local function removeTree(path)
if love.filesystem.isDirectory(path) then local info = love.filesystem.getInfo(path)
if info and info.type == "directory" then
for _, child in ipairs(love.filesystem.getDirectoryItems(path)) do for _, child in ipairs(love.filesystem.getDirectoryItems(path)) do
removeTree(path .. "/" .. child) removeTree(path .. "/" .. child)
end end
@ -60,7 +68,7 @@ function clearRuntimeSaveData()
end end
-- These are the only files this game writes through love.filesystem: room -- These are the only files this game writes through love.filesystem: room
-- autosaves and the generated asset cache. Source levels are read-only to -- autosaves and the generated asset cache. Source levels are read-only to
-- this API and cannot be removed here. -- this API and cannot be removed here.
removeTree("rooms") removeTree("rooms")
if love.filesystem.remove("assets.prop") then removed = removed + 1 end if love.filesystem.remove("assets.prop") then removed = removed + 1 end
@ -102,16 +110,18 @@ function cellShapeFromBox(box)
return cells return cells
end end
-- Which 16x16 cells of a sprite are non-empty, for multi-cell collision shapes. -- Which 16x16 cells of an image are non-empty, for multi-cell collision
function getCellsFromSprite(sprite) -- shapes. LÖVE 11 no longer exposes Image:getData(), so inspect ImageData
local w, h = sprite:getWidth(), sprite:getHeight() -- directly instead of creating a GPU Image first.
function getCellsFromSprite(spritePath)
local img = love.image.newImageData(spritePath)
local w, h = img:getWidth(), img:getHeight()
local box = {w = w / 16, h = h / 16} local box = {w = w / 16, h = h / 16}
local boxCells = cellShapeFromBox(box) local boxCells = cellShapeFromBox(box)
if box.w < 1 or box.h < 1 then if box.w < 1 or box.h < 1 then
return boxCells return boxCells
end end
local img = sprite:getData()
local cellsToRemove = {} local cellsToRemove = {}
for _, cell in pairs(boxCells) do for _, cell in pairs(boxCells) do

View file

@ -0,0 +1,3 @@
{
class="immoveable"
}

3
art/food/banana.meta Normal file
View file

@ -0,0 +1,3 @@
{
class="immoveable"
}

1
art/food/beer_mug.meta Normal file
View file

@ -0,0 +1 @@
{class="moveable"}

1
art/food/wine.meta Normal file
View file

@ -0,0 +1 @@
{class="moveable"}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 879 B

After

Width:  |  Height:  |  Size: 338 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 890 B

After

Width:  |  Height:  |  Size: 353 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 879 B

After

Width:  |  Height:  |  Size: 340 B

Before After
Before After

3
art/tech/image.meta Normal file
View file

@ -0,0 +1,3 @@
{
class="moveable"
}

3
art/tech/image2.meta Normal file
View file

@ -0,0 +1,3 @@
{
class="immoveable"
}

View file

@ -4,10 +4,11 @@
Color = class("Color") Color = class("Color")
Color.static.list = { Color.static.list = {
red = {r = 255, g = 0, b = 0, a = 255}, -- LÖVE 11 represents color components as normalized floats, not bytes.
green = {r = 0, g = 255, b = 0, a = 255}, red = {r = 1, g = 0, b = 0, a = 1},
blue = {r = 0, g = 0, b = 255, a = 255}, green = {r = 0, g = 1, b = 0, a = 1},
white = {r = 255, g = 255, b = 255, a = 255} blue = {r = 0, g = 0, b = 1, a = 1},
white = {r = 1, g = 1, b = 1, a = 1}
} }
function Color:initialize(inColor) function Color:initialize(inColor)
@ -27,4 +28,4 @@ end
function randomColor() function randomColor()
local colors = {"red", "green", "blue"} local colors = {"red", "green", "blue"}
return colors[math.random(1, 3)] return colors[math.random(1, 3)]
end end

View file

@ -1,5 +1,12 @@
function love.conf(t) function love.conf(t)
-- This project uses the normalized-color and ImageData APIs introduced in
-- LÖVE 11. Pinning the intended runtime also gives a useful warning when a
-- player tries to launch it with an older installation.
t.version = "11.5"
t.window.width = 900 t.window.width = 900
t.window.height = 640 t.window.height = 640
t.window.title = "CHROMA SOLSTICE" t.window.title = "CHROMA SOLSTICE"
end t.window.resizable = true
t.window.minwidth = 700
t.window.minheight = 500
end

11
dialogable.lua Normal file
View file

@ -0,0 +1,11 @@
-- Dialogable: a mixin that makes any Entity subclass talkable. `include` it and
-- give the entity a `self.book` (an Ink book); bumping the entity then opens the
-- modal Dialogue. npcs, signs, artifacts — anything you can talk to — are just
-- Dialogables. For now an entity carries one book; later it can swap books as it
-- progresses. All it does is route a bump to the dialogue manager.
Dialogable = {}
function Dialogable:onBump()
if self.book then Dialogue.open(self.book) end
return true
end

View file

@ -0,0 +1,110 @@
-- Dead-simple modal dialogue overlay driven by a narrator (Ink) story.
-- No variables, no saved state: open a book, walk its paragraphs, pick a
-- choice, then a keypress dismisses it. `narrator` is the global set up in
-- main.lua; Dialogable entities (see npc.lua) call Dialogue.open on bump.
Dialogue = {}
local story -- active narrator story, or nil when the box is closed
local paragraphs = {} -- text lines currently shown
local choices = {} -- current choices (empty once we reach an ending)
local selected = 1
function Dialogue.isOpen()
return story ~= nil
end
-- Run the story forward to the next choice point (or the end), gathering every
-- paragraph printed along the way.
local function advance()
paragraphs = {}
while story:can_continue() do
for _, p in ipairs(story:continue()) do
table.insert(paragraphs, p.text)
end
end
choices = story:can_choose() and story:get_choices() or {}
selected = 1
end
function Dialogue.open(book)
if story then return end -- already talking: ignore repeat bumps
story = narrator.init_story(book)
story:begin()
advance()
end
function Dialogue.close()
story, paragraphs, choices, selected = nil, {}, {}, 1
end
function Dialogue.keypressed(key)
if #choices == 0 then
-- an ending is on screen: any key dismisses the box
Dialogue.close()
elseif key == "up" then
selected = selected > 1 and selected - 1 or #choices
elseif key == "down" then
selected = selected < #choices and selected + 1 or 1
elseif key == "return" or key == "space" or key == "x" then
story:choose(selected)
advance()
end
end
-- LCD font sized to the current play area, rebuilt only when the size changes,
-- so dialogue text scales with the game like every sprite does.
local dlgFont, dlgFontSize
local function dialogueFont()
local size = math.max(8, math.floor(height / gridHeight * 0.4))
if not dlgFont or size ~= dlgFontSize then
dlgFont = love.graphics.newFont("font/LCD_Solid.ttf", size)
dlgFontSize = size
end
return dlgFont
end
function Dialogue.draw()
if not story then return end
local fnt = dialogueFont()
local fh = fnt:getHeight()
local pad, lineH = fh * 0.8, fh * 1.3
local boxH = height * 0.4
local y0 = height - boxH
local wrap = width - pad * 2
-- Dialogue obeys the world's colour rule: only the channels the player has
-- are rendered. One colour -> that colour; all three -> white (additive), so
-- a red-only player literally reads the sign in red.
local has = { red = false, green = false, blue = false }
for _, c in ipairs(currentRoom.colorsPlayerHas) do has[c] = true end
local r = has.red and 1 or 0
local g = has.green and 1 or 0
local b = has.blue and 1 or 0
local prevFont = love.graphics.getFont()
love.graphics.setFont(fnt)
love.graphics.setColor(0, 0, 0, 0.82)
love.graphics.rectangle("fill", 0, y0, width, boxH)
local y = y0 + pad
love.graphics.setColor(r, g, b, 1)
for _, text in ipairs(paragraphs) do
love.graphics.printf(text, pad, y, wrap)
local _, lines = fnt:getWrap(text, wrap)
y = y + math.max(1, #lines) * lineH
end
y = y + lineH * 0.5
for i, choice in ipairs(choices) do
local dim = (i == selected) and 1 or 0.5 -- selected is brighter + "> "
love.graphics.setColor(r * dim, g * dim, b * dim, 1)
local line = ((i == selected) and "> " or " ") .. choice.text
love.graphics.printf(line, pad, y, wrap)
local _, lines = fnt:getWrap(line, wrap)
y = y + math.max(1, #lines) * lineH
end
love.graphics.setFont(prevFont)
end

84
docs/adding-npcs.md Normal file
View file

@ -0,0 +1,84 @@
# Adding a talkable NPC
An NPC/sign is just an immovable `Entity` that mixes in `Dialogable`. Bumping it
(a rejected move) opens the dialogue box. Talking is the same everywhere — you
only supply **art** + **a story**.
## 1. Write the story
Create `stories/<name>.ink` — a line, a few choices, an ending:
```ink
yo wassup
* kick the sign
it doesnt budge.
* [say nothing] you have nothing to say to a sign.
- -> END
```
## 2. Compile it
The game can't parse `.ink` (LÖVE has no lpeg), so compile to a `.lua` book:
```sh
lua tools/compile_ink.lua <name>
```
Re-run this **every time you edit the `.ink`** — the game loads the `.lua`, not
the `.ink`. (Needs `lua` + `lua-lpeg` installed.)
## 3. Load the book
In `main.lua`, next to `startSignBook`:
```lua
<name>Book = require "stories.<name>"
```
## 4. Make the entity
In `npc.lua`, copy `Sign` — pick your art and name your book:
```lua
Oracle = class("Oracle", Entity)
Oracle:include(Dialogable)
function Oracle:initialize(x, y)
self.book = oracleBook -- from step 3
Entity.initialize(self, {
x = x, y = y, color = "red",
collision = "unmoveable",
sprites = {
red = loadSprite("art/npc/oracle_r.png"),
green = loadSprite("art/npc/oracle_g.png"),
blue = loadSprite("art/npc/oracle_b.png"),
},
})
end
```
Per-channel art lives in `art/npc/` as `<name>_r/_g/_b.png` (16px cells).
## 5. Place it in a room
Map a save class to your entity in `Room:createEntity` (`room.lua`), alongside
the `npc``Sign` branch:
```lua
if class == "oracle" then
local entity = Oracle:new(x, y)
self:registerNpc(entity)
return entity
end
```
Then add it to a room's `rooms/<room>.sav` `objects` list:
```lua
{ class = "oracle", x = 4, y = 8 }
```
That's it — walk into it and it talks. NPCs are stamped into every colour's
collision matrix, so they block movement and obey the world's colour rules (a
red-only player reads them in red).

View file

@ -0,0 +1,50 @@
# Dialogue consequences — next steps
Design for dialogue trees with consequences across conversations ("talk again
does something else", NPC A affects NPC B, dialogue opens doors). Builds on the
existing narrator (Ink) setup — see `dialogue_manager.lua`, `dialogable.lua`.
## Core principle
**Books are disposable views; a global `WorldState` table is the single source
of truth.** Each Ink book reads flags in and writes flags out; it holds no
authority, so it can be re-`init`ed and thrown away without losing anything.
This resolves book-per-character's weakness (book A's visit counts are invisible
to book B) — B branches on a `WorldState` flag, not on A's runtime state.
Mirrors the existing world-vs-room split (`world.lua`: "the world is only a
layout; rooms own their puzzle state"). Narrative progress is a third, global
category with its own save file.
Keep book-per-character (`Dialogable.book`), add a stable `self.storyId`.
## The bug to fix first
`Dialogue.open` re-`init`s the story every bump (`dialogue_manager.lua:31-33`),
discarding all state — so there's no "again" to hang consequences on.
## Steps
1. Add a global `WorldState = {}` (flags + counters).
2. `saveProgress` / `loadProgress``save/progress.sav` via TSerial +
`readFromSource` / `writeToSource` (helpers already exist, used in
`world.lua` / `world_seed.lua`). Shape: `{ version = 1, flags = WorldState }`.
Flat table, no per-book blobs initially.
3. `Dialogue.bridge(story)` on open:
- `story:bind("flag", fn)` — read a WorldState flag from Ink.
- `story:bind("setflag", fn)` — write one back.
- gameplay binds, e.g. `give_color` / `open_door` calling into `currentRoom`.
4. Cache the live story per `storyId` in the Dialogue module (stop re-init'ing).
5. Hook `saveProgress` into the existing autosave points (near `room.lua:814`).
"Talk again does X" = increment an own `WorldState` counter (e.g.
`sage_talk_count`) on open, branch on it in Ink. No reliance on Ink
visit-count persistence.
## Deferred
Persisting `story:save_state()` (plain table, keyed by `storyId`) only buys
in-book niceties — consumed sticky choices, resume mid-conversation across a
hard quit. Skipped initially: it couples the save file to book structure and
forces narrator's `migrate` hook on every `.ink` edit. `WorldState` flags don't
— a renamed flag just reads `nil`/false.

View file

@ -16,13 +16,18 @@ function Entity:initialize(t)
self.collision = t.collision --"none", "moveable", "unmoveable" self.collision = t.collision --"none", "moveable", "unmoveable"
self.sprite = repSprite self.sprite = repSprite
self.shaAmount = t.sha or 1 self.shaAmount = t.sha or 1
self.onBump = t.onBump or function(self) return true end -- onBump is a method (see Entity:onBump) so mixins like Dialogable can
-- override it; a per-instance t.onBump still wins when supplied.
if t.onBump then self.onBump = t.onBump end
self.tick = t.tick or function(self) return true end self.tick = t.tick or function(self) return true end
self.drawOffset = {x = 0, y = 0} self.drawOffset = {x = 0, y = 0}
if self.size.w < 1 or self.size.h < 1 then if self.size.w < 1 or self.size.h < 1 then
self.drawOffset.x = (drawScale * 16 * self.size.w) / 2 -- Small sprites (notably 10px switches) are drawn inside a 16px cell.
self.drawOffset.y = (drawScale * 16 * self.size.h) / 2 -- Offset by the leftover space in native sprite-pixels (e.g. (16-10)/2 = 3);
-- drawScale is applied at draw time so this stays correct across resizes.
self.drawOffset.x = (16 - self.spriteSize.w) / 2
self.drawOffset.y = (16 - self.spriteSize.h) / 2
end end
end end
@ -119,7 +124,7 @@ function Entity:draw(channel)
if not sprite then return end if not sprite then return end
love.graphics.setColor(gColor[channel]:set()) love.graphics.setColor(gColor[channel]:set())
local p = self:getDrawPos() local p = self:getDrawPos()
love.graphics.draw(sprite, p.x + self.drawOffset.x, p.y + self.drawOffset.y, 0, drawScale, drawScale) love.graphics.draw(sprite, p.x + self.drawOffset.x * drawScale, p.y + self.drawOffset.y * drawScale, 0, drawScale, drawScale)
end end
function Entity:canMove() function Entity:canMove()
@ -157,7 +162,11 @@ function Entity:setSprite(sprite)
end end
function Entity:sha() function Entity:sha()
return math.random(-worldSha, worldSha) * .5 return math.random(-worldSha, worldSha) * .125 * drawScale
end
function Entity:onBump()
return true
end end
function Entity:bump() function Entity:bump()

View file

@ -3,8 +3,48 @@ Input = {}
Input.playermovekeys = { up = "up", down = "down", left = "left", right = "right" } Input.playermovekeys = { up = "up", down = "down", left = "left", right = "right" }
Input.playerswitchkeys = { ["1"] = "1", ["2"] = "2", ["3"] = "3"} Input.playerswitchkeys = { ["1"] = "1", ["2"] = "2", ["3"] = "3"}
Input.colorfromkey = {"red", "green", "blue"} Input.colorfromkey = {"red", "green", "blue"}
Input.moveRepeatDelay = .28
Input.moveRepeatInterval = .09
Input.heldMoveKey = nil
Input.moveRepeatElapsed = 0
Input.moveRepeatStarted = false
function Input:handler(dt) function Input:handler(dt)
local key = self.heldMoveKey
if key and not love.keyboard.isDown(key) then
key = nil
for _, candidate in ipairs({"up", "down", "left", "right"}) do
if love.keyboard.isDown(candidate) then key = candidate; break end
end
self.heldMoveKey = key
self.moveRepeatElapsed = 0
self.moveRepeatStarted = false
end
if not key then return nil end
self.moveRepeatElapsed = self.moveRepeatElapsed + dt
if not self.moveRepeatStarted then
if self.moveRepeatElapsed >= self.moveRepeatDelay then
self.moveRepeatElapsed = 0
self.moveRepeatStarted = true
return key
end
elseif self.moveRepeatElapsed >= self.moveRepeatInterval then
self.moveRepeatElapsed = 0
return key
end
end
function Input:beginMove(key)
self.heldMoveKey = key
self.moveRepeatElapsed = 0
self.moveRepeatStarted = false
end
function Input:stopMoveRepeat()
self.heldMoveKey = nil
self.moveRepeatElapsed = 0
self.moveRepeatStarted = false
end end
@ -24,4 +64,4 @@ end
function Input.playerColor(key) function Input.playerColor(key)
return Input.colorfromkey[tonumber(key)] return Input.colorfromkey[tonumber(key)]
end end

View file

@ -33,12 +33,59 @@ local editorRoomName = nil
local roomNameField = nil local roomNameField = nil
local editorStatus = nil local editorStatus = nil
local clearSaveConfirmationExpires = 0 local clearSaveConfirmationExpires = 0
local clearSaveConfirmationScope = nil
local worldSeedConfirmationExpires = 0 local worldSeedConfirmationExpires = 0
local worldMode = "place" local worldMode = "place"
local worldRoomButtons = {} local worldRoomButtons = {}
local worldControls = {} local worldControls = {}
local worldPage = 1 local worldPage = 1
local worldPageSize = 5 local worldPageSize = 5
local assetSearchText = ""
local assetSearchButtons = {}
local worldSearchText = ""
local worldSearchBox = { x = 10, y = 34, w = 0, h = 28 }
local function fuzzyMatches(value, query)
query = (query or ""):lower():gsub("%s+", "")
if query == "" then return true end
local index = 1
for character in value:lower():gmatch(".") do
if character == query:sub(index, index) then
index = index + 1
if index > #query then return true end
end
end
return false
end
local function beginSearch(field)
activeField = field
inputText = field == "assetSearch" and assetSearchText or worldSearchText
response = inputText
end
local function rebuildAssetSearchButtons()
assetSearchButtons = {}
if assetSearchText == "" then return end
local names = {}
for asset in pairs(gameAssets) do
if fuzzyMatches(asset, assetSearchText) then table.insert(names, asset) end
end
table.sort(names)
for index, asset in ipairs(names) do
if index > 8 then break end
local sprites = {}
for color, location in pairs(gameAssets[asset].sprites) do sprites[color] = love.graphics.newImage(location) end
table.insert(assetSearchButtons, createUIElement{
sprites = sprites,
name = "asset",
text = asset,
x = 30,
y = 45 + (index - 1) * 62,
onClic = { left = function() selectAsset(asset) end }
})
end
end
local function buttonContains(button, x, y) 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 return x > button.x and x < button.x + button.w and y > button.y and y < button.y + button.h
@ -46,14 +93,17 @@ end
local function rebuildWorldRoomButtons() local function rebuildWorldRoomButtons()
worldRoomButtons = {} worldRoomButtons = {}
local names = gameWorld:listRoomNames() local names = {}
for _, name in ipairs(gameWorld:listRoomNames()) do
if fuzzyMatches(name, worldSearchText) then table.insert(names, name) end
end
local pages = math.max(1, math.ceil(#names / worldPageSize)) local pages = math.max(1, math.ceil(#names / worldPageSize))
worldPage = constrain(worldPage, 1, pages) worldPage = constrain(worldPage, 1, pages)
local first = (worldPage - 1) * worldPageSize + 1 local first = (worldPage - 1) * worldPageSize + 1
for index = first, math.min(first + worldPageSize - 1, #names) do for index = first, math.min(first + worldPageSize - 1, #names) do
local roomName = names[index] local roomName = names[index]
local row = index - first local row = index - first
table.insert(worldRoomButtons, { name = roomName, x = 10, y = 36 + row * 68, w = sideBarBox.w - 20, h = 60 }) table.insert(worldRoomButtons, { name = roomName, x = 10, y = 70 + row * 68, w = sideBarBox.w - 20, h = 60 })
end end
worldControls = { worldControls = {
{ text = worldMode == "place" and "MODE: PLACE ROOMS" or "MODE: OPEN LEVELS", x = 10, y = sideBarBox.h - 194, w = sideBarBox.w - 20, h = 34, { text = worldMode == "place" and "MODE: PLACE ROOMS" or "MODE: OPEN LEVELS", x = 10, y = sideBarBox.h - 194, w = sideBarBox.w - 20, h = 34,
@ -81,16 +131,19 @@ end
local function drawWorldSidebar() local function drawWorldSidebar()
love.graphics.setColor(gColor.white:set()) 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") love.graphics.printf("ROOMS — " .. (worldMode == "place" and "select to place; re-click to clear" or "click a map room to open"), 8, 6, sideBarBox.w - 16, "center")
worldSearchBox.w = sideBarBox.w - 20
love.graphics.rectangle("line", worldSearchBox.x, worldSearchBox.y, worldSearchBox.w, worldSearchBox.h)
love.graphics.printf("SEARCH: " .. worldSearchText, worldSearchBox.x + 6, worldSearchBox.y + 7, worldSearchBox.w - 12, "left")
for _, button in ipairs(worldRoomButtons) do 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.setColor(button.name == selectedRoom and 1 or 100 / 255, button.name == selectedRoom and 1 or 100 / 255, button.name == selectedRoom and 1 or 100 / 255, 1)
love.graphics.rectangle("line", button.x, button.y, button.w, button.h) 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) gameWorld:drawRoomPreview(button.name, button.x + 4, button.y + 4, button.h - 8)
love.graphics.setColor(gColor.white:set()) 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") love.graphics.printf(button.name, button.x + button.h + 4, button.y + 21, button.w - button.h - 8, "left")
end end
for _, control in ipairs(worldControls) do for _, control in ipairs(worldControls) do
love.graphics.setColor(255, 255, 255, 255) love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("line", control.x, control.y, control.w, control.h) 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") love.graphics.printf(control.text, control.x, control.y + 9, control.w, "center")
end end
@ -129,7 +182,8 @@ local function saveRoomFromEditor()
return return
end end
if not currentRoom:saveToSource(name) then local savePlayerPositions = love.keyboard.isDown("lshift", "rshift")
if not currentRoom:saveToSource(name, savePlayerPositions, oldName) then
editorStatus = "Could not save '" .. name .. "'." editorStatus = "Could not save '" .. name .. "'."
return return
end end
@ -146,20 +200,28 @@ local function saveRoomFromEditor()
editorRoomName = name editorRoomName = name
roomNameField:editText(name) roomNameField:editText(name)
activeField = nil activeField = nil
editorStatus = "Saved source level '" .. name .. ".'" editorStatus = "Saved source level '" .. name .. "'" .. (savePlayerPositions and " with player positions." or ".")
end end
local function clearRuntimeSaves() local function clearRuntimeSaves()
local now = love.timer.getTime() local now = love.timer.getTime()
if now > clearSaveConfirmationExpires then local clearAll = love.keyboard.isDown("lshift", "rshift")
local roomName = currentRoom.name
local scope = clearAll and "all" or roomName
if now > clearSaveConfirmationExpires or clearSaveConfirmationScope ~= scope then
clearSaveConfirmationExpires = now + 3 clearSaveConfirmationExpires = now + 3
editorStatus = "Click the clear-save icon again within 3 seconds to erase runtime saves." clearSaveConfirmationScope = scope
if clearAll then
editorStatus = "Shift-click the clear-save icon again within 3 seconds to erase runtime saves for every room."
else
editorStatus = "Click the clear-save icon again within 3 seconds to erase the runtime save for '" .. (roomName or "this room") .. "'."
end
return return
end end
local removed = clearRuntimeSaveData() local removed = clearRuntimeSaveData(clearAll and nil or roomName)
clearSaveConfirmationExpires = 0 clearSaveConfirmationExpires = 0
local roomName = currentRoom.name clearSaveConfirmationScope = nil
if roomName then if roomName then
currentRoom = Room:new{ name = roomName } currentRoom = Room:new{ name = roomName }
setEditorRoomName(currentRoom.name) setEditorRoomName(currentRoom.name)
@ -184,7 +246,10 @@ function _initLevelEditor()
table.insert(hierarchy[gameAssets[asset].directory], asset) table.insert(hierarchy[gameAssets[asset].directory], asset)
end end
for folder, object in pairs(hierarchy) do local folders = {}
for folder in pairs(hierarchy) do table.insert(folders, folder) end
table.sort(folders)
for _, folder in ipairs(folders) do
local button = createUIElement{ local button = createUIElement{
name = "folder", name = "folder",
text = folder, text = folder,
@ -310,7 +375,8 @@ function selectFolder(folder)
local counter = 1 local counter = 1
local limit = 10 local limit = 10
for _, asset in pairs(hierarchy[selectedFolder]) do table.sort(hierarchy[selectedFolder])
for _, asset in ipairs(hierarchy[selectedFolder]) do
if counter % limit == 0 then if counter % limit == 0 then
counter = 1 counter = 1
@ -328,7 +394,7 @@ function selectFolder(folder)
end end
} }
} }
table.insert(assetButtons[row], forwardbutton) table.insert(assetButtons[row], forwardButton)
row = row + 1 row = row + 1
table.insert(assetButtons, {}) table.insert(assetButtons, {})
@ -521,6 +587,7 @@ local function openWorldRoom(cellX, cellY)
currentRoom = Room:new{ name = name } currentRoom = Room:new{ name = name }
editorStatus = "Editing '" .. name .. ".'" editorStatus = "Editing '" .. name .. ".'"
end end
gameWorld:setLastRoom(currentRoom.name)
setEditorRoomName(currentRoom.name) setEditorRoomName(currentRoom.name)
assetsView() assetsView()
end end
@ -531,7 +598,17 @@ function editorMouseHandler(x, y, button)
x = x - width x = x - width
if editorView == "room" then if editorView == "room" then
if not selectedFolder then if buttonContains({ x = 10, y = 5, w = sideBarBox.w - 20, h = 28 }, x, y) then
beginSearch("assetSearch")
return
elseif assetSearchText ~= "" then
for _, uiElement in ipairs(assetSearchButtons) do
if uiElement:clicIsInBox(x, y) then
uiElement:onClic(button)
return
end
end
elseif not selectedFolder then
for _, uiElement in pairs(folderButtons) do for _, uiElement in pairs(folderButtons) do
if uiElement:clicIsInBox(x, y) then if uiElement:clicIsInBox(x, y) then
uiElement:onClic(button) uiElement:onClic(button)
@ -559,6 +636,10 @@ function editorMouseHandler(x, y, button)
end end
end end
elseif editorView == "world" then elseif editorView == "world" then
if buttonContains(worldSearchBox, x, y) then
beginSearch("worldSearch")
return
end
for _, roomButton in ipairs(worldRoomButtons) do for _, roomButton in ipairs(worldRoomButtons) do
if buttonContains(roomButton, x, y) then if buttonContains(roomButton, x, y) then
if selectedRoom == roomButton.name then if selectedRoom == roomButton.name then
@ -573,7 +654,7 @@ function editorMouseHandler(x, y, button)
end end
for _, control in ipairs(worldControls) do for _, control in ipairs(worldControls) do
if buttonContains(control, x, y) then if buttonContains(control, x, y) then
control:onClic(x) control.onClic(x)
return return
end end
end end
@ -606,6 +687,15 @@ function editorTextHandler(text)
elseif editorView == "room" and activeField == "roomName" then elseif editorView == "room" and activeField == "roomName" then
inputText = text inputText = text
roomNameField:editText(text) roomNameField:editText(text)
elseif editorView == "room" and activeField == "assetSearch" then
inputText = text
assetSearchText = text
rebuildAssetSearchButtons()
elseif editorView == "world" and activeField == "worldSearch" then
inputText = text
worldSearchText = text
worldPage = 1
rebuildWorldRoomButtons()
end end
end end
@ -614,6 +704,10 @@ function editorCompleteResponse()
assetProperties[activeField] = inputText assetProperties[activeField] = inputText
elseif editorView == "room" and activeField == "roomName" then elseif editorView == "room" and activeField == "roomName" then
editorRoomName = inputText editorRoomName = inputText
elseif editorView == "room" and activeField == "assetSearch" then
assetSearchText = inputText
elseif editorView == "world" and activeField == "worldSearch" then
worldSearchText = inputText
end end
activeField = nil activeField = nil
end end
@ -628,12 +722,17 @@ end
function editorDraw() function editorDraw()
if editorView == "room" then if editorView == "room" then
love.graphics.setColor(gColor.white:set())
love.graphics.rectangle("line", 10, 5, sideBarBox.w - 20, 28)
love.graphics.printf("ASSET SEARCH: " .. assetSearchText, 16, 12, sideBarBox.w - 32, "left")
love.graphics.setColor(gColor.white:set()) love.graphics.setColor(gColor.white:set())
love.graphics.printf("LEVEL NAME", 0, sideBarBox.h * (7 / 10) - 24, sideBarBox.w, "center") love.graphics.printf("LEVEL NAME", 0, sideBarBox.h * (7 / 10) - 24, sideBarBox.w, "center")
for _, button in pairs(uiButtons) do for _, button in pairs(uiButtons) do
button:draw() button:draw()
end end
if not selectedFolder then if assetSearchText ~= "" then
for _, asset in ipairs(assetSearchButtons) do asset:draw() end
elseif not selectedFolder then
for _, folder in pairs(folderButtons) do for _, folder in pairs(folderButtons) do
folder:draw() folder:draw()
end end

View file

@ -1,6 +1,6 @@
GUI = class("level_editor/gui") GUI = class("level_editor/gui")
GUI.static.pressSound = love.audio.newSource("sounds/ui/button.wav") GUI.static.pressSound = love.audio.newSource("sounds/ui/button.wav", "static")
GUI.static.defaultCallbac = function() print("this button has not been configured") end GUI.static.defaultCallbac = function() print("this button has not been configured") end

View file

@ -34,7 +34,7 @@ function getAllAssets()
-- class comes from a <name>.meta sitting next to the art -- class comes from a <name>.meta sitting next to the art
-- (authoritative); default to immoveable if none exists -- (authoritative); default to immoveable if none exists
local metaPath = topFolder .. "/" .. folder .. "/" .. name .. ".meta" local metaPath = topFolder .. "/" .. folder .. "/" .. name .. ".meta"
if love.filesystem.exists(metaPath) then if love.filesystem.getInfo(metaPath, "file") then
globalAssetProperties[name].class = TSerial.unpack(love.filesystem.read(metaPath)).class globalAssetProperties[name].class = TSerial.unpack(love.filesystem.read(metaPath)).class
elseif not globalAssetProperties[name].class then elseif not globalAssetProperties[name].class then
globalAssetProperties[name].class = "immoveable" globalAssetProperties[name].class = "immoveable"
@ -45,7 +45,7 @@ function getAllAssets()
if not globalAssetProperties[name].cells or reanalyzeCells then if not globalAssetProperties[name].cells or reanalyzeCells then
globalAssetProperties[name].cells = {} globalAssetProperties[name].cells = {}
globalAssetProperties[name].cells[color] = getCellsFromSprite(love.graphics.newImage(sprite)) globalAssetProperties[name].cells[color] = getCellsFromSprite(sprite)
end end
end end
end end
@ -54,4 +54,4 @@ function getAllAssets()
end end
return globalAssetProperties return globalAssetProperties
end end

1
libs/narrator Submodule

@ -0,0 +1 @@
Subproject commit 45a8b03553f2385716c24ec762757e418b2b73c1

View file

@ -15,6 +15,12 @@ require "world_seed"
require "shaders" require "shaders"
require "level_editor/editor" 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).
package.path = package.path .. ";libs/narrator/?.lua"
narrator = require "narrator.narrator"
startSignBook = require "stories.start_sign"
function love.load() function love.load()
globalAssetProperties = {} -- rebuilt from the filesystem (art/ + .meta) by getAllAssets globalAssetProperties = {} -- rebuilt from the filesystem (art/ + .meta) by getAllAssets
@ -25,23 +31,39 @@ function love.load()
gameWorld = World:new() gameWorld = World:new()
_initLevelEditor() _initLevelEditor()
gameCanvas = love.graphics.newCanvas() _initCanvases()
sideBarCanvas = love.graphics.newCanvas(sideBarBox.w, sideBarBox.h)
finalCanvas = love.graphics.newCanvas()
inputTimer = Timer.new() 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{ currentRoom = Room:new{
name = "start" -- TEMP: load a migrated level to eyeball it; revert once the room picker lands name = gameWorld.lastRoom or "start",
runtime = true
} }
gameWorld:setLastRoom(currentRoom.name)
setEditorRoomName(currentRoom.name) setEditorRoomName(currentRoom.name)
response = "" response = ""
end 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()
if currentRoom then
currentRoom:save()
if gameWorld then gameWorld:setLastRoom(currentRoom.name) end
end
end
function love.update(dt) function love.update(dt)
Input:handler(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) currentRoom:update(dt)
-- if love.keyboard.isDown("w") then worldSha = worldSha + 1 end -- if love.keyboard.isDown("w") then worldSha = worldSha + 1 end
@ -67,7 +89,7 @@ function love.draw()
love.graphics.push() love.graphics.push()
for i = 1, samples do for i = 1, samples do
local c = 65 / (i * 1.75) local c = 65 / 255 / (i * 1.75)
love.graphics.setColor(c, c, c) love.graphics.setColor(c, c, c)
love.graphics.scale(scaleCoefficient, scaleCoefficient) love.graphics.scale(scaleCoefficient, scaleCoefficient)
love.graphics.translate((width / 2 - (width * scaleCoefficient) / 2) + 2, (width / 2 - (height * scaleCoefficient) / 2) + 2) love.graphics.translate((width / 2 - (width * scaleCoefficient) / 2) + 2, (width / 2 - (height * scaleCoefficient) / 2) + 2)
@ -75,7 +97,7 @@ function love.draw()
end end
love.graphics.pop() love.graphics.pop()
love.graphics.setColor(255, 255, 255) love.graphics.setColor(1, 1, 1)
love.graphics.draw(gameCanvas, 0, 0) love.graphics.draw(gameCanvas, 0, 0)
love.graphics.setCanvas() love.graphics.setCanvas()
@ -85,15 +107,23 @@ function love.draw()
love.graphics.setCanvas() love.graphics.setCanvas()
love.graphics.setBlendMode("alpha") love.graphics.setBlendMode("alpha")
love.graphics.setColor(255, 255, 255, 255) love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(finalCanvas, 0, 0) love.graphics.draw(finalCanvas, 0, 0)
love.graphics.draw(sideBarCanvas, sideBarBox.x, sideBarBox.y) love.graphics.draw(sideBarCanvas, sideBarBox.x, sideBarBox.y)
love.graphics.print("Current FPS: "..tostring(love.timer.getFPS( )), 10, 10) love.graphics.print("Current FPS: "..tostring(love.timer.getFPS( )), 10, 10)
Dialogue.draw()
end end
function love.keypressed(key) function love.keypressed(key)
-- 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 if editorView ~= "world" and Input.isPlayerMove(key) then
Input:beginMove(key)
currentRoom:movePlayer(key) currentRoom:movePlayer(key)
elseif editorView ~= "world" and Input.isPlayerSwitch(key) then elseif editorView ~= "world" and Input.isPlayerSwitch(key) then
local c = Input.playerColor(key) local c = Input.playerColor(key)
@ -134,12 +164,32 @@ function love.textinput(t)
editorTextHandler(response) editorTextHandler(response)
end end
function love.resize(w, h)
_computeDimensions()
drawScale = width / gridWidth / 16
_initCanvases()
end
function _initDefaults() function _initDefaults()
--set filter --set filter
love.graphics.setDefaultFilter("linear", "nearest") love.graphics.setDefaultFilter("linear", "nearest")
love.filesystem.createDirectory("rooms") love.filesystem.createDirectory("rooms")
--set global width / height variables --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() width = love.graphics.getWidth()
height = love.graphics.getHeight() height = love.graphics.getHeight()
if width > height then if width > height then
@ -148,17 +198,16 @@ function _initDefaults()
height = width height = width
end end
--set randomseed
math.randomseed(os.time())
--font
font = love.graphics.newFont("font/LCD_Solid.ttf", 16)
love.graphics.setFont(font)
--sidebar
sideBarBox = {x = width, y = 0, w = love.graphics.getWidth() - width, h = height} sideBarBox = {x = width, y = 0, w = love.graphics.getWidth() - width, h = height}
end end
-- (Re)create the render targets at the current window size.
function _initCanvases()
gameCanvas = love.graphics.newCanvas()
finalCanvas = love.graphics.newCanvas()
sideBarCanvas = love.graphics.newCanvas(math.max(1, sideBarBox.w), math.max(1, sideBarBox.h))
end
function _initGridVars() function _initGridVars()
gridWidth, gridHeight = 11, 11 gridWidth, gridHeight = 11, 11
drawScale = width / gridWidth / 16 drawScale = width / gridWidth / 16

28
npc.lua
View file

@ -1,14 +1,22 @@
npc = class("npc", Entity) require "entity"
require "dialogable"
function npc:initialize(x, y, color, name) -- A dialogable placed in the world. Signs, npcs and artifacts will each be a
-- small Entity subclass that includes Dialogable and names its own book; Sign is
-- the first. It just picks art + a story — the talking lives in the mixin.
Sign = class("Sign", Entity)
Sign:include(Dialogable)
function Sign:initialize(x, y)
self.book = startSignBook
Entity.initialize(self, { Entity.initialize(self, {
x = x, x = x, y = y,
y = y, color = "red", -- representative only; sprites are per-channel
color = color,
collision = "unmoveable", collision = "unmoveable",
sprite = sprites.npc[name][color], sprites = {
onBump = function(self) red = loadSprite("art/npc/start_sign_r.png"),
green = loadSprite("art/npc/start_sign_g.png"),
end, blue = loadSprite("art/npc/start_sign_b.png"),
},
}) })
end end

View file

@ -3,8 +3,8 @@ require "entity"
Player = class("Player", Entity) Player = class("Player", Entity)
Player.static.sounds = { Player.static.sounds = {
switchFail = love.audio.newSource("sounds/switch_fail.wav"), switchFail = love.audio.newSource("sounds/switch_fail.wav", "static"),
switchSuccess = love.audio.newSource("sounds/switch_success.wav") switchSuccess = love.audio.newSource("sounds/switch_success.wav", "static")
} }
function Player:initialize(x, y, color) function Player:initialize(x, y, color)

107
room.lua
View file

@ -27,6 +27,10 @@ function Room:initialize(t)
print(t.name) print(t.name)
if t.player then if t.player then
-- The carried colors are the sole source of truth for what's active.
-- Copies that live in this room (loaded from the sav) start INACTIVE —
-- you must walk onto one to merge it; it never joins you automatically.
self.activeColors = {red = false, green = false, blue = false}
-- entering from another room: place the carried colors at the entry point -- entering from another room: place the carried colors at the entry point
for _, color in ipairs(t.player.activeColors) do for _, color in ipairs(t.player.activeColors) do
if self.player[color] then if self.player[color] then
@ -36,11 +40,6 @@ function Room:initialize(t)
end end
self:setActiveColor(color, true) self:setActiveColor(color, true)
end end
elseif not next(self.player) then
-- fresh load of a room with no saved player: default red spawn so it's
-- playable (the player starts with only red)
self:registerPlayer(Player:new(1, 1, "red"), "red")
self:setActiveColor("red", true)
end end
self.name = t.name self.name = t.name
@ -102,6 +101,11 @@ function Room:initialize(t)
self.collidableMatrices = {} --{red = {{...}{...}{...}}, ... self.collidableMatrices = {} --{red = {{...}{...}{...}}, ...
self:createSwitchMatrix() self:createSwitchMatrix()
self:createCollideablesMatrix() self:createCollideablesMatrix()
-- Resolve switches/doors up front so the very first frame renders correct
-- lock state: a block already sitting on a switch presses it before anything
-- is drawn, instead of waiting for the first move's tick.
self:resolveSwitchesAndDoors()
end end
@ -116,19 +120,11 @@ function Room:draw()
-- draw only the channels the player has. Each object just exposes sprites per -- draw only the channels the player has. Each object just exposes sprites per
-- channel via Entity:draw(channel); a door registered on all three channels -- channel via Entity:draw(channel); a door registered on all three channels
-- naturally draws its red sprite on the red pass, etc. No per-object logic. -- naturally draws its red sprite on the red pass, etc. No per-object logic.
local has = {}
for _, channel in ipairs(self.colorsPlayerHas) do for _, channel in ipairs(self.colorsPlayerHas) do
has[channel] = true
if self.player[channel] then self.player[channel]:draw(channel) end if self.player[channel] then self.player[channel]:draw(channel) end
for _, entity in pairs(self.collideables[channel]) do entity:draw(channel) end for _, entity in pairs(self.collideables[channel]) do entity:draw(channel) end
for _, switch in pairs(self.switches[channel]) do switch:draw(channel) end for _, switch in pairs(self.switches[channel]) do switch:draw(channel) end
end for _, npc in ipairs(self.npcs) do npc:draw(channel) end
-- npcs (old "sages") are markers for now: a "?" tinted to the player's channels
local cellW, cellH = width / gridWidth, height / gridHeight
love.graphics.setColor(has.red and 255 or 0, has.green and 255 or 0, has.blue and 255 or 0)
for _, npc in ipairs(self.npcs) do
love.graphics.print("?", (npc.x - 1) * cellW + cellW * 0.3, (npc.y - 1) * cellH + cellH * 0.1, 0, 2, 2)
end end
end end
@ -136,6 +132,14 @@ function Room:createCollideablesMatrix()
for color, entities in pairs(self.collideables) do for color, entities in pairs(self.collideables) do
self.collidableMatrices[color] = self:createEntityMatrix(entities) self.collidableMatrices[color] = self:createEntityMatrix(entities)
end end
-- Dialogables (npcs) block every channel, so stamp them into each matrix.
for _, npc in ipairs(self.npcs) do
for _, cell in ipairs(npc:getOccupiedCells()) do
for color in pairs(self.collidableMatrices) do
self.collidableMatrices[color][cell.y][cell.x] = npc
end
end
end
end end
function Room:createSwitchMatrix() function Room:createSwitchMatrix()
@ -419,11 +423,17 @@ function Room:tick()
end end
self:createCollideablesMatrix() self:createCollideablesMatrix()
self:createSwitchMatrix() self:createSwitchMatrix()
self:resolveSwitchesAndDoors()
self:nextRoomCheck()
end
-- Run the switch press check and push the result to every door. Assumes the
-- collidable/switch matrices are already current.
function Room:resolveSwitchesAndDoors()
local setDoorColors = self:switchCheck() local setDoorColors = self:switchCheck()
for _, door in pairs(self.doors) do for _, door in pairs(self.doors) do
door:setColors(setDoorColors) door:setColors(setDoorColors)
end end
self:nextRoomCheck()
end end
function Room:playerJoinCheck(previousPositions, newPositions) function Room:playerJoinCheck(previousPositions, newPositions)
@ -489,7 +499,10 @@ function Room:switchCheck()
local onSwitches = {} local onSwitches = {}
for _, switch in pairs(switchArray) do for _, switch in pairs(switchArray) do
local targetCell = self:getCellInMatrix(self.collidableMatrices[color], switch:getGridPos()) local targetCell = self:getCellInMatrix(self.collidableMatrices[color], switch:getGridPos())
if targetCell then local player = self.player[color]
-- Players are not stored in collidableMatrices, but a colour copy
-- standing on its matching switch presses it just like a block does.
if targetCell or (player and player:occupiesCell(switch:getGridPos())) then
--switch is on --switch is on
switch:activate() switch:activate()
table.insert(onSwitches, switch) --doesn't rly matter what we put here as long as it's true-y table.insert(onSwitches, switch) --doesn't rly matter what we put here as long as it's true-y
@ -528,8 +541,9 @@ end
-- keyword handled directly since it has no art. -- keyword handled directly since it has no art.
function Room:createEntity(class, color, x, y) function Room:createEntity(class, color, x, y)
if class == "npc" then if class == "npc" then
self:registerNpc(x, y) local entity = Sign:new(x, y)
return self:registerNpc(entity)
return entity
end end
local props = globalAssetProperties and globalAssetProperties[class] local props = globalAssetProperties and globalAssetProperties[class]
@ -589,8 +603,11 @@ function Room:registerPlayer(entityPointer, color)
self.player[color] = entityPointer self.player[color] = entityPointer
end end
function Room:registerNpc(x, y) function Room:registerNpc(entity)
table.insert(self.npcs, { x = x, y = y }) -- npcs are dialogable entities: they carry .x/.y like any Entity, so
-- serialize's npc loop still round-trips them, and they get stamped into the
-- collision matrix (see createCollideablesMatrix) so a bump is a rejected move.
table.insert(self.npcs, entity)
end end
function Room:getCellFromMousePos(mouseX, mouseY) function Room:getCellFromMousePos(mouseX, mouseY)
@ -685,6 +702,12 @@ end
function Room:deserialize(saveTable) function Room:deserialize(saveTable)
for _, obj in ipairs(saveTable.objects or {}) do for _, obj in ipairs(saveTable.objects or {}) do
self:createEntity(obj.class, obj.color, obj.x, obj.y) self:createEntity(obj.class, obj.color, obj.x, obj.y)
-- createEntity turns every loaded player on; honour a saved active flag so
-- copies that were off at save time stay off (older savs lack the field and
-- keep the old all-on behaviour).
if obj.class == "player" and obj.active ~= nil then
self:setActiveColor(obj.color, obj.active)
end
end end
end end
@ -699,13 +722,37 @@ end
-- This is intentionally separate from Room:save. It is the editor's explicit -- This is intentionally separate from Room:save. It is the editor's explicit
-- "write this level into the project" action; gameplay autosaves must never -- "write this level into the project" action; gameplay autosaves must never
-- call it. -- call it.
function Room:saveToSource(name) function Room:saveToSource(name, savePlayerPositions, playerSourceName)
name = name or self.name or "test" name = name or self.name or "test"
local room = TSerial.pack(self:serialize(), nil, true) local saveTable = self:serialize(savePlayerPositions ~= false)
if savePlayerPositions == false then
-- A normal editor save keeps the authored spawn positions from the
-- existing source level. During a rename that source is still oldName.
local sourceName = playerSourceName or name
local existing = readFromSource("rooms/" .. sourceName .. ".sav")
local keptPlayer = false
if existing then
local previous = TSerial.unpack(existing)
for _, object in ipairs(previous.objects or {}) do
if object.class == "player" then
table.insert(saveTable.objects, object)
keptPlayer = true
end
end
end
-- A newly named blank level has no prior source spawn to retain.
if not keptPlayer then
for color, player in pairs(self.player) do
local pos = player:getGridPos()
table.insert(saveTable.objects, { class = "player", color = color, x = pos.x, y = pos.y })
end
end
end
local room = TSerial.pack(saveTable, nil, true)
return writeToSource("rooms/" .. name .. ".sav", room) return writeToSource("rooms/" .. name .. ".sav", room)
end end
function Room:serialize() function Room:serialize(includePlayers)
local objects = {} local objects = {}
local function emit(class, color, entity) local function emit(class, color, entity)
@ -713,8 +760,17 @@ function Room:serialize()
table.insert(objects, { class = class, color = color, x = pos.x, y = pos.y }) table.insert(objects, { class = class, color = color, x = pos.x, y = pos.y })
end end
for color, player in pairs(self.player) do if includePlayers ~= false then
emit("player", color, player) for color, player in pairs(self.player) do
local pos = player:getGridPos()
-- Persist which copies were active so a resume restores exactly the
-- channels that were on at save time. A colour deactivated during play
-- (see playerJoinCheck) must come back as an inactive copy, not turn on.
table.insert(objects, {
class = "player", color = color, x = pos.x, y = pos.y,
active = self.activeColors[color] and true or false,
})
end
end end
for color, collideableArray in pairs(self.collideables) do for color, collideableArray in pairs(self.collideables) do
@ -766,6 +822,7 @@ function nextRoom(pos)
x = newPos.x, y = newPos.y, activeColors = currentRoom:getActiveColors() x = newPos.x, y = newPos.y, activeColors = currentRoom:getActiveColors()
} }
} }
if gameWorld then gameWorld:setLastRoom(currentRoom.name) end
if currentRoom.name then setEditorRoomName(currentRoom.name) end if currentRoom.name then setEditorRoomName(currentRoom.name) end
end end

76
rooms/jorge_room.sav Normal file
View file

@ -0,0 +1,76 @@
{
objects={
{
color="red",
y=4,
x=8,
class="toilet"
},
{
color="red",
y=4,
x=6,
class="toilet"
},
{
color="red",
y=4,
x=4,
class="toilet"
},
{
color="green",
y=4,
x=8,
class="toilet"
},
{
color="green",
y=4,
x=6,
class="toilet"
},
{
color="green",
y=4,
x=4,
class="toilet"
},
{
color="blue",
y=4,
x=8,
class="toilet"
},
{
color="blue",
y=4,
x=6,
class="toilet"
},
{
color="blue",
y=4,
x=4,
class="toilet"
},
{
color="red",
y=8,
x=6,
class="player"
},
{
color="green",
y=8,
x=6,
class="player"
},
{
color="blue",
y=8,
x=6,
class="player"
}
}
}

4
rooms/room_11_09.sav Normal file
View file

@ -0,0 +1,4 @@
{
objects={
}
}

View file

@ -1,538 +1,538 @@
{ {
objects={ objects={
{ {
color="green",
class="static_wall", class="static_wall",
x=1,
y=1
},
{
color="blue", color="blue",
class="static_wall", y=1,
x=1, x=1
y=1
}, },
{ {
color="red",
class="static_wall", class="static_wall",
x=1,
y=1
},
{
color="green",
class="static_wall",
x=11,
y=1
},
{
color="blue", color="blue",
class="static_wall", y=1,
x=11, x=11
y=1
}, },
{ {
color="red",
class="static_wall", class="static_wall",
x=11,
y=1
},
{
color="blue", color="blue",
class="static_wall", y=2,
x=1, x=1
y=2
}, },
{ {
color="green",
class="static_wall", class="static_wall",
x=1,
y=2
},
{
color="red",
class="static_wall",
x=1,
y=2
},
{
color="red",
class="static_wall",
x=11,
y=2
},
{
color="green",
class="static_wall",
x=11,
y=2
},
{
color="blue", color="blue",
class="static_wall", y=2,
x=11, x=11
y=2
}, },
{ {
class="static_wall",
color="blue", color="blue",
class="static_wall", y=3,
x=1, x=1
y=3
}, },
{ {
color="red",
class="static_wall", class="static_wall",
x=1,
y=3
},
{
color="green",
class="static_wall",
x=1,
y=3
},
{
color="blue", color="blue",
class="static_wall", y=3,
x=11, x=11
y=3
}, },
{ {
color="green",
class="static_wall", class="static_wall",
x=11,
y=3
},
{
color="red",
class="static_wall",
x=11,
y=3
},
{
color="green",
class="static_wall",
x=1,
y=4
},
{
color="blue", color="blue",
class="static_wall", y=4,
x=1, x=1
y=4
}, },
{ {
color="red",
class="static_wall", class="static_wall",
x=1,
y=4
},
{
color="green",
class="static_wall",
x=11,
y=4
},
{
color="blue", color="blue",
class="static_wall", y=4,
x=11, x=11
y=4
}, },
{ {
color="red",
class="static_wall", class="static_wall",
x=11,
y=4
},
{
color="blue", color="blue",
class="static_wall", y=5,
x=1, x=1
y=5
}, },
{ {
color="green",
class="static_wall", class="static_wall",
x=1,
y=5
},
{
color="red",
class="static_wall",
x=1,
y=5
},
{
color="green",
class="static_wall",
x=11,
y=5
},
{
color="red",
class="static_wall",
x=11,
y=5
},
{
color="green",
class="static_wall",
x=1,
y=6
},
{
color="red",
class="static_wall",
x=1,
y=6
},
{
color="blue", color="blue",
class="static_wall", y=6,
x=1, x=1
y=6
}, },
{ {
color="red",
class="static_wall", class="static_wall",
x=11,
y=6
},
{
color="green",
class="static_wall",
x=11,
y=6
},
{
color="blue", color="blue",
class="static_wall", y=6,
x=11, x=11
y=6
}, },
{ {
color="red",
class="static_wall", class="static_wall",
x=1,
y=7
},
{
color="green",
class="static_wall",
x=1,
y=7
},
{
color="blue", color="blue",
class="static_wall", y=7,
x=1, x=1
y=7
}, },
{ {
color="red",
class="static_wall", class="static_wall",
x=11,
y=7
},
{
color="green",
class="static_wall",
x=11,
y=7
},
{
color="blue", color="blue",
class="static_wall", y=7,
x=11, x=11
y=7
}, },
{ {
color="green",
class="static_wall", class="static_wall",
x=1,
y=8
},
{
color="blue", color="blue",
class="static_wall", y=8,
x=1, x=1
y=8
}, },
{ {
color="red",
class="static_wall", class="static_wall",
x=1,
y=8
},
{
color="red",
class="static_wall",
x=11,
y=8
},
{
color="green",
class="static_wall",
x=11,
y=8
},
{
color="blue", color="blue",
class="static_wall", y=8,
x=11, x=11
y=8
}, },
{ {
color="red",
class="static_wall", class="static_wall",
x=1,
y=9
},
{
color="blue", color="blue",
class="static_wall", y=9,
x=1, x=1
y=9
}, },
{ {
color="green",
class="static_wall", class="static_wall",
x=1,
y=9
},
{
color="blue", color="blue",
class="static_wall", y=9,
x=11, x=11
y=9
}, },
{ {
color="green",
class="static_wall", class="static_wall",
x=11,
y=9
},
{
color="red",
class="static_wall",
x=11,
y=9
},
{
color="red",
class="static_wall",
x=1,
y=10
},
{
color="blue", color="blue",
class="static_wall", y=10,
x=1, x=1
y=10
}, },
{ {
color="green",
class="static_wall", class="static_wall",
x=1,
y=10
},
{
color="red",
class="static_wall",
x=11,
y=10
},
{
color="green",
class="static_wall",
x=11,
y=10
},
{
color="blue", color="blue",
class="static_wall", y=10,
x=11, x=11
y=10
}, },
{ {
color="red",
class="static_wall", class="static_wall",
x=1,
y=11
},
{
color="green",
class="static_wall",
x=1,
y=11
},
{
color="blue", color="blue",
class="static_wall", y=11,
x=1, x=1
y=11
}, },
{ {
color="green",
class="static_wall", class="static_wall",
x=2,
y=11
},
{
color="blue", color="blue",
class="static_wall", y=11,
x=2, x=2
y=11
}, },
{ {
color="red",
class="static_wall", class="static_wall",
x=2,
y=11
},
{
color="blue", color="blue",
class="static_wall", y=11,
x=3, x=3
y=11
}, },
{ {
color="green",
class="static_wall", class="static_wall",
x=3,
y=11
},
{
color="red",
class="static_wall",
x=3,
y=11
},
{
color="green",
class="static_wall",
x=4,
y=11
},
{
color="red",
class="static_wall",
x=4,
y=11
},
{
color="blue", color="blue",
class="static_wall", y=11,
x=4, x=4
y=11
}, },
{ {
class="static_wall",
color="blue", color="blue",
class="static_wall", y=11,
x=5, x=5
y=11
}, },
{ {
color="green",
class="static_wall", class="static_wall",
x=5,
y=11
},
{
color="red",
class="static_wall",
x=5,
y=11
},
{
color="red",
class="static_wall",
x=7,
y=11
},
{
color="blue", color="blue",
class="static_wall", y=11,
x=7, x=7
y=11
}, },
{ {
color="green",
class="static_wall", class="static_wall",
x=7,
y=11
},
{
color="blue", color="blue",
class="static_wall", y=11,
x=8, x=8
y=11
}, },
{ {
color="red",
class="static_wall", class="static_wall",
x=8,
y=11
},
{
color="green",
class="static_wall",
x=8,
y=11
},
{
color="blue", color="blue",
class="static_wall", y=11,
x=9, x=9
y=11
}, },
{ {
color="red",
class="static_wall", class="static_wall",
x=9,
y=11
},
{
color="green",
class="static_wall",
x=9,
y=11
},
{
color="red",
class="static_wall",
x=10,
y=11
},
{
color="green",
class="static_wall",
x=10,
y=11
},
{
color="blue", color="blue",
class="static_wall", y=11,
x=10, x=10
y=11
}, },
{ {
class="static_wall",
color="blue", color="blue",
class="static_wall", y=11,
x=11, x=11
y=11
}, },
{ {
color="green",
class="static_wall", class="static_wall",
x=11,
y=11
},
{
color="red", color="red",
y=1,
x=1
},
{
class="static_wall", class="static_wall",
x=11, color="red",
y=11 y=1,
x=11
},
{
class="static_wall",
color="red",
y=2,
x=1
},
{
class="static_wall",
color="red",
y=2,
x=11
},
{
class="static_wall",
color="red",
y=3,
x=1
},
{
class="static_wall",
color="red",
y=3,
x=11
},
{
class="static_wall",
color="red",
y=4,
x=1
},
{
class="static_wall",
color="red",
y=4,
x=11
},
{
class="static_wall",
color="red",
y=5,
x=1
},
{
class="static_wall",
color="red",
y=5,
x=11
},
{
class="static_wall",
color="red",
y=6,
x=1
},
{
class="static_wall",
color="red",
y=6,
x=11
},
{
class="static_wall",
color="red",
y=7,
x=1
},
{
class="static_wall",
color="red",
y=7,
x=11
},
{
class="static_wall",
color="red",
y=8,
x=1
},
{
class="static_wall",
color="red",
y=8,
x=11
},
{
class="static_wall",
color="red",
y=9,
x=1
},
{
class="static_wall",
color="red",
y=9,
x=11
},
{
class="static_wall",
color="red",
y=10,
x=1
},
{
class="static_wall",
color="red",
y=10,
x=11
},
{
class="static_wall",
color="red",
y=11,
x=1
},
{
class="static_wall",
color="red",
y=11,
x=2
},
{
class="static_wall",
color="red",
y=11,
x=3
},
{
class="static_wall",
color="red",
y=11,
x=4
},
{
class="static_wall",
color="red",
y=11,
x=5
},
{
class="static_wall",
color="red",
y=11,
x=7
},
{
class="static_wall",
color="red",
y=11,
x=8
},
{
class="static_wall",
color="red",
y=11,
x=9
},
{
class="static_wall",
color="red",
y=11,
x=10
},
{
class="static_wall",
color="red",
y=11,
x=11
},
{
class="static_wall",
color="green",
y=1,
x=1
},
{
class="static_wall",
color="green",
y=1,
x=11
},
{
class="static_wall",
color="green",
y=2,
x=1
},
{
class="static_wall",
color="green",
y=2,
x=11
},
{
class="static_wall",
color="green",
y=3,
x=1
},
{
class="static_wall",
color="green",
y=3,
x=11
},
{
class="static_wall",
color="green",
y=4,
x=1
},
{
class="static_wall",
color="green",
y=4,
x=11
},
{
class="static_wall",
color="green",
y=5,
x=1
},
{
class="static_wall",
color="green",
y=5,
x=11
},
{
class="static_wall",
color="green",
y=6,
x=1
},
{
class="static_wall",
color="green",
y=6,
x=11
},
{
class="static_wall",
color="green",
y=7,
x=1
},
{
class="static_wall",
color="green",
y=7,
x=11
},
{
class="static_wall",
color="green",
y=8,
x=1
},
{
class="static_wall",
color="green",
y=8,
x=11
},
{
class="static_wall",
color="green",
y=9,
x=1
},
{
class="static_wall",
color="green",
y=9,
x=11
},
{
class="static_wall",
color="green",
y=10,
x=1
},
{
class="static_wall",
color="green",
y=10,
x=11
},
{
class="static_wall",
color="green",
y=11,
x=1
},
{
class="static_wall",
color="green",
y=11,
x=2
},
{
class="static_wall",
color="green",
y=11,
x=3
},
{
class="static_wall",
color="green",
y=11,
x=4
},
{
class="static_wall",
color="green",
y=11,
x=5
},
{
class="static_wall",
color="green",
y=11,
x=7
},
{
class="static_wall",
color="green",
y=11,
x=8
},
{
class="static_wall",
color="green",
y=11,
x=9
},
{
class="static_wall",
color="green",
y=11,
x=10
},
{
class="static_wall",
color="green",
y=11,
x=11
} }
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -18,24 +18,6 @@ objects={
x=6, x=6,
color="green" color="green"
}, },
{
y=11,
class="player",
x=6,
color="blue"
},
{
y=11,
class="player",
x=6,
color="red"
},
{
y=11,
class="player",
x=6,
color="green"
},
{ {
y=1, y=1,
class="static_wall", class="static_wall",
@ -1207,4 +1189,4 @@ objects={
color="red" color="red"
} }
} }
} }

View file

@ -1,23 +1,5 @@
{ {
objects={ objects={
{
y=2,
class="player",
x=6,
color="blue"
},
{
y=3,
class="player",
x=2,
color="red"
},
{
y=3,
class="player",
x=7,
color="green"
},
{ {
y=1, y=1,
class="static_wall", class="static_wall",
@ -1009,4 +991,4 @@ objects={
color="green" color="green"
} }
} }
} }

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -30,18 +30,6 @@ objects={
x=13, x=13,
color="blue" color="blue"
}, },
{
y=11,
class="player",
x=6,
color="red"
},
{
y=11,
class="player",
x=6,
color="green"
},
{ {
y=1, y=1,
class="static_wall", class="static_wall",
@ -1039,4 +1027,4 @@ objects={
color="green" color="green"
} }
} }
} }

View file

@ -12,12 +12,6 @@ objects={
x=6, x=6,
color="red" color="red"
}, },
{
y=11,
class="player",
x=6,
color="green"
},
{ {
y=1, y=1,
class="static_wall", class="static_wall",
@ -979,4 +973,4 @@ objects={
color="red" color="red"
} }
} }
} }

8
stories/start_sign.ink Normal file
View file

@ -0,0 +1,8 @@
yo wassup
* kick the sign
it doesnt budge.
* kiss the sign
You step away, a little embarrassed.
* [say nothing] you have nothing to say to a sign.
- -> END

1
stories/start_sign.lua Normal file
View file

@ -0,0 +1 @@
return {["version"]={["engine"]=2,["tree"]=1},["inclusions"]={},["lists"]={},["tree"]={["_"]={["_"]={"yo wassup",{["node"]={"it doesnt budge."},["text"]="kick the sign",["choice"]="kick the sign"},{["node"]={"You step away, a little embarrassed."},["text"]="kiss the sign",["choice"]="kiss the sign"},{["text"]="you have nothing to say to a sign.",["choice"]="say nothing"},{["divert"]={["path"]="END",["tunnel"]=false}}}}},["constants"]={},["params"]={},["variables"]={}}

31
tools/compile_ink.lua Normal file
View file

@ -0,0 +1,31 @@
-- Compile Ink source (stories/*.ink) into the pre-parsed .lua books the game
-- loads. LÖVE ships no lpeg, so parsing happens here, once, at author time;
-- the game only ever requires the compiled .lua. Needs a standalone `lua` with
-- lpeg installed (Arch: pacman -S lua-lpeg).
--
-- Run from the project root:
-- lua tools/compile_ink.lua # compile every stories/*.ink
-- lua tools/compile_ink.lua start_sign # compile stories/start_sign.ink
-- lua tools/compile_ink.lua stories/foo.ink # explicit path
package.path = package.path .. ";libs/narrator/?.lua"
local narrator = require("narrator.narrator")
local function compile(path)
narrator.parse_file(path, { save = true })
print("compiled " .. path .. " -> " .. (path:gsub("%.ink$", "") .. ".lua"))
end
local targets = {}
if #arg == 0 then
local ls = io.popen("ls stories/*.ink 2>/dev/null")
for line in ls:lines() do table.insert(targets, line) end
ls:close()
else
for _, a in ipairs(arg) do
table.insert(targets, a:match("%.ink$") and a or ("stories/" .. a .. ".ink"))
end
end
assert(#targets > 0, "no .ink files to compile")
for _, t in ipairs(targets) do compile(t) end

29
tools/nuke_love_saves.sh Executable file
View file

@ -0,0 +1,29 @@
#!/usr/bin/env sh
# Delete Chroma Solstice's *runtime* LÖVE save data. Project levels in
# ./rooms are not affected.
set -eu
DATA_HOME=${XDG_DATA_HOME:-"$HOME/.local/share"}
SAVE_DIR="$DATA_HOME/love/chroma-solstice"
if [ "${1:-}" != "--yes" ]; then
printf '%s\n' "This deletes only the local LÖVE runtime saves at:"
printf '%s\n' " $SAVE_DIR"
printf '%s\n' "Repository files, including ./rooms/*.sav, are not touched."
printf '%s\n' "Run: $0 --yes"
exit 1
fi
if [ ! -e "$SAVE_DIR" ]; then
printf '%s\n' "No local LÖVE saves found at: $SAVE_DIR"
exit 0
fi
# Keep the destructive target exact even if this script is changed later.
case "$SAVE_DIR" in
"$DATA_HOME/love/chroma-solstice") ;;
*) printf '%s\n' "Refusing unexpected save path: $SAVE_DIR" >&2; exit 2 ;;
esac
rm -rf -- "$SAVE_DIR"
printf '%s\n' "Deleted local LÖVE saves: $SAVE_DIR"

View file

@ -8,6 +8,7 @@ function World:initialize()
self.rooms = {} self.rooms = {}
self.roomByName = {} self.roomByName = {}
self.previewCache = {} self.previewCache = {}
self.lastRoom = nil
self:load() self:load()
end end
@ -26,6 +27,7 @@ function World:load()
if not raw then return end if not raw then return end
local ok, save = pcall(TSerial.unpack, raw) local ok, save = pcall(TSerial.unpack, raw)
if not ok or type(save) ~= "table" then return end if not ok or type(save) ~= "table" then return end
self.lastRoom = save.lastRoom
for _, room in ipairs(save.rooms or {}) do for _, room in ipairs(save.rooms or {}) do
if room.name and self:isInBounds(room.x, room.y) then if room.name and self:isInBounds(room.x, room.y) then
self:_putRoom(room.name, room.x, room.y) self:_putRoom(room.name, room.x, room.y)
@ -38,7 +40,7 @@ function World:save()
for _, room in ipairs(self.rooms) do for _, room in ipairs(self.rooms) do
table.insert(rooms, { name = room.name, x = room.x, y = room.y }) table.insert(rooms, { name = room.name, x = room.x, y = room.y })
end end
return writeToSource("rooms/world.sav", TSerial.pack({ width = self.width, height = self.height, rooms = rooms }, nil, true)) return writeToSource("rooms/world.sav", TSerial.pack({ width = self.width, height = self.height, lastRoom = self.lastRoom, rooms = rooms }, nil, true))
end end
function World:isInBounds(x, y) function World:isInBounds(x, y)
@ -87,6 +89,7 @@ function World:removeRoom(x, y)
if room.x == x and room.y == y then if room.x == x and room.y == y then
self.roomByName[room.name] = nil self.roomByName[room.name] = nil
table.remove(self.rooms, i) table.remove(self.rooms, i)
if self.lastRoom == room.name then self.lastRoom = nil end
return self:save(), room.name return self:save(), room.name
end end
end end
@ -99,11 +102,20 @@ function World:renameRoom(oldName, newName)
self.roomByName[oldName] = nil self.roomByName[oldName] = nil
room.name = newName room.name = newName
self.roomByName[newName] = room self.roomByName[newName] = room
if self.lastRoom == oldName then self.lastRoom = newName end
self:invalidatePreview(oldName) self:invalidatePreview(oldName)
self:invalidatePreview(newName) self:invalidatePreview(newName)
return self:save() return self:save()
end end
function World:setLastRoom(name)
if name and self.lastRoom ~= name then
self.lastRoom = name
return self:save()
end
return true
end
function World:getNeighbor(name, exit) function World:getNeighbor(name, exit)
local room = self:roomLocation(name) local room = self:roomLocation(name)
if not room then return nil end if not room then return nil end
@ -144,12 +156,17 @@ function World:drawRoomPreview(name, x, y, size)
love.graphics.setScissor(x, y, size, size) love.graphics.setScissor(x, y, size, size)
for _, object in ipairs(room.objects or {}) do for _, object in ipairs(room.objects or {}) do
if object.class == "npc" then if object.class == "npc" then
love.graphics.setColor(255, 255, 255, 255) love.graphics.setColor(1, 1, 1, 1)
love.graphics.print("?", x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, 0, scale, scale) love.graphics.print("?", x + (object.x - 1) * 16 * scale, y + (object.y - 1) * 16 * scale, 0, scale, scale)
else else
local props = globalAssetProperties[object.class] local props = globalAssetProperties[object.class]
if props and props.sprites then if props and props.sprites then
local colors = object.class == "door" and { "red", "green", "blue" } or { object.color } local colors
if object.class == "door" then
colors = object.color and { object.color } or { "red", "green", "blue" }
else
colors = { object.color }
end
for _, color in ipairs(colors) do for _, color in ipairs(colors) do
local path = props.sprites[color] local path = props.sprites[color]
if path then if path then
@ -165,14 +182,14 @@ end
function World:draw() function World:draw()
local cell = width / self.width local cell = width / self.width
love.graphics.setColor(12, 12, 20, 255) love.graphics.setColor(12 / 255, 12 / 255, 20 / 255, 1)
love.graphics.rectangle("fill", 0, 0, width, height) love.graphics.rectangle("fill", 0, 0, width, height)
for y = 1, self.height do for y = 1, self.height do
for x = 1, self.width do for x = 1, self.width do
local screenX, screenY = (x - 1) * cell, (y - 1) * cell local screenX, screenY = (x - 1) * cell, (y - 1) * cell
local name = self:roomAt(x, y) local name = self:roomAt(x, y)
if name then self:drawRoomPreview(name, screenX, screenY, cell) end 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.setColor(name and 1 or 70 / 255, name and 1 or 70 / 255, name and 1 or 90 / 255, 1)
love.graphics.rectangle("line", screenX, screenY, cell, cell) love.graphics.rectangle("line", screenX, screenY, cell, cell)
end end
end end