From 5413823b26e78e29d2b93a96bd61dfd04d8aaabd Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 8 Aug 2026 00:09:44 -0400 Subject: [PATCH] add dialog --- dialogable.lua | 11 +++++ dialogue_manager.lua | 110 +++++++++++++++++++++++++++++++++++++++++ docs/adding-npcs.md | 84 +++++++++++++++++++++++++++++++ entity.lua | 8 ++- libs/narrator | 1 + main.lua | 15 +++++- npc.lua | 28 +++++++---- room.lua | 30 ++++++----- rooms/jorge_room.sav | 76 ++++++++++++++++++++++++++++ rooms/world.sav | 19 ++++--- stories/start_sign.ink | 8 +++ stories/start_sign.lua | 1 + tools/compile_ink.lua | 31 ++++++++++++ 13 files changed, 390 insertions(+), 32 deletions(-) create mode 100644 dialogable.lua create mode 100644 docs/adding-npcs.md create mode 160000 libs/narrator create mode 100644 rooms/jorge_room.sav create mode 100644 stories/start_sign.ink create mode 100644 stories/start_sign.lua create mode 100644 tools/compile_ink.lua diff --git a/dialogable.lua b/dialogable.lua new file mode 100644 index 0000000..5da0ed4 --- /dev/null +++ b/dialogable.lua @@ -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 diff --git a/dialogue_manager.lua b/dialogue_manager.lua index e69de29..51effcf 100644 --- a/dialogue_manager.lua +++ b/dialogue_manager.lua @@ -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 diff --git a/docs/adding-npcs.md b/docs/adding-npcs.md new file mode 100644 index 0000000..584b439 --- /dev/null +++ b/docs/adding-npcs.md @@ -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/.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 +``` + +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 +Book = require "stories." +``` + +## 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 `_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/.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). diff --git a/entity.lua b/entity.lua index c7a2381..fc2adb2 100644 --- a/entity.lua +++ b/entity.lua @@ -16,7 +16,9 @@ function Entity:initialize(t) self.collision = t.collision --"none", "moveable", "unmoveable" self.sprite = repSprite 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.drawOffset = {x = 0, y = 0} @@ -163,6 +165,10 @@ function Entity:sha() return math.random(-worldSha, worldSha) * .125 * drawScale end +function Entity:onBump() + return true +end + function Entity:bump() return self:onBump() end diff --git a/libs/narrator b/libs/narrator new file mode 160000 index 0000000..45a8b03 --- /dev/null +++ b/libs/narrator @@ -0,0 +1 @@ +Subproject commit 45a8b03553f2385716c24ec762757e418b2b73c1 diff --git a/main.lua b/main.lua index 3caf642..05e7211 100644 --- a/main.lua +++ b/main.lua @@ -15,6 +15,12 @@ require "world_seed" require "shaders" require "level_editor/editor" +-- Ink dialogue: ship the pre-parsed book (LÖVE has no lpeg to parse .ink at +-- runtime; the narrator parser degrades to false without it, runtime is fine). +package.path = package.path .. ";libs/narrator/?.lua" +narrator = require "narrator.narrator" +startSignBook = require "stories.start_sign" + function love.load() globalAssetProperties = {} -- rebuilt from the filesystem (art/ + .meta) by getAllAssets @@ -56,7 +62,7 @@ function love.update(dt) Input:stopMoveRepeat() else local repeatedMove = Input:handler(dt) - if repeatedMove then currentRoom:movePlayer(repeatedMove) end + if repeatedMove and not Dialogue.isOpen() then currentRoom:movePlayer(repeatedMove) end end currentRoom:update(dt) @@ -106,9 +112,16 @@ function love.draw() love.graphics.draw(sideBarCanvas, sideBarBox.x, sideBarBox.y) love.graphics.print("Current FPS: "..tostring(love.timer.getFPS( )), 10, 10) + Dialogue.draw() end 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 Input:beginMove(key) currentRoom:movePlayer(key) diff --git a/npc.lua b/npc.lua index 7aee18d..8ed69ec 100644 --- a/npc.lua +++ b/npc.lua @@ -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, { - x = x, - y = y, - color = color, + x = x, y = y, + color = "red", -- representative only; sprites are per-channel collision = "unmoveable", - sprite = sprites.npc[name][color], - onBump = function(self) - - end, + sprites = { + red = loadSprite("art/npc/start_sign_r.png"), + green = loadSprite("art/npc/start_sign_g.png"), + blue = loadSprite("art/npc/start_sign_b.png"), + }, }) -end \ No newline at end of file +end diff --git a/room.lua b/room.lua index e06f7a6..99d0478 100644 --- a/room.lua +++ b/room.lua @@ -120,19 +120,11 @@ function Room:draw() -- 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 -- naturally draws its red sprite on the red pass, etc. No per-object logic. - local has = {} for _, channel in ipairs(self.colorsPlayerHas) do - has[channel] = true if self.player[channel] then self.player[channel]: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 - 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 1 or 0, has.green and 1 or 0, has.blue and 1 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) + for _, npc in ipairs(self.npcs) do npc:draw(channel) end end end @@ -140,6 +132,14 @@ function Room:createCollideablesMatrix() for color, entities in pairs(self.collideables) do self.collidableMatrices[color] = self:createEntityMatrix(entities) 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 function Room:createSwitchMatrix() @@ -541,8 +541,9 @@ end -- keyword handled directly since it has no art. function Room:createEntity(class, color, x, y) if class == "npc" then - self:registerNpc(x, y) - return + local entity = Sign:new(x, y) + self:registerNpc(entity) + return entity end local props = globalAssetProperties and globalAssetProperties[class] @@ -602,8 +603,11 @@ function Room:registerPlayer(entityPointer, color) self.player[color] = entityPointer end -function Room:registerNpc(x, y) - table.insert(self.npcs, { x = x, y = y }) +function Room:registerNpc(entity) + -- 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 function Room:getCellFromMousePos(mouseX, mouseY) diff --git a/rooms/jorge_room.sav b/rooms/jorge_room.sav new file mode 100644 index 0000000..8fc4d5b --- /dev/null +++ b/rooms/jorge_room.sav @@ -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" + } +} +} \ No newline at end of file diff --git a/rooms/world.sav b/rooms/world.sav index 08012aa..fb9521d 100644 --- a/rooms/world.sav +++ b/rooms/world.sav @@ -135,18 +135,23 @@ rooms={ y=9, x=11 }, - { - name="4", - y=3, - x=5 - }, { name="yellow_skull", y=3, x=9 + }, + { + name="jorge_room", + y=5, + x=5 + }, + { + name="4", + y=10, + x=11 } }, -lastRoom="start", +lastRoom="yellow_4", width=18, height=18 -} +} \ No newline at end of file diff --git a/stories/start_sign.ink b/stories/start_sign.ink new file mode 100644 index 0000000..053b0b0 --- /dev/null +++ b/stories/start_sign.ink @@ -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 diff --git a/stories/start_sign.lua b/stories/start_sign.lua new file mode 100644 index 0000000..e55c9e0 --- /dev/null +++ b/stories/start_sign.lua @@ -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"]={}} \ No newline at end of file diff --git a/tools/compile_ink.lua b/tools/compile_ink.lua new file mode 100644 index 0000000..4ae5dd2 --- /dev/null +++ b/tools/compile_ink.lua @@ -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