diff --git a/apps/adventure-arena/controllers/LibraryController.lua b/apps/adventure-arena/controllers/LibraryController.lua index 3aad6b5a..36fecd97 100644 --- a/apps/adventure-arena/controllers/LibraryController.lua +++ b/apps/adventure-arena/controllers/LibraryController.lua @@ -17,13 +17,12 @@ function Controller.new(options) }, Controller) end --- Where a saved story stands, in the words a reader uses: the chapter and --- the room, then the status line and how far the score has come. +-- Where a saved story stands, in the words a reader uses: the room, then +-- the status line and how far the score has come. function Controller:progressEntry(record) local game = self.model:find(record.gameId) if not game then return nil end - local chapter = (record.chapter or 0) > 0 and ("Chapter " .. Session.roman(record.chapter)) or nil - local place = chapter and record.room and (chapter .. " · " .. record.room) or record.room or game.title + local place = record.room or game.title local maxScore = tonumber(record.maxScore) or 0 return { game = game, diff --git a/apps/adventure-arena/controllers/ReadingSettingsController.lua b/apps/adventure-arena/controllers/ReadingSettingsController.lua index 2c1b1ccb..3ef523d5 100644 --- a/apps/adventure-arena/controllers/ReadingSettingsController.lua +++ b/apps/adventure-arena/controllers/ReadingSettingsController.lua @@ -52,6 +52,7 @@ function Controller:actions() decreaseSize = function() self:apply(model:adjustFontSize(-1)) end, increaseSize = function() self:apply(model:adjustFontSize(1)) end, justifyChanged = function(on) self:apply(model:setJustified(on)) end, + dropCapChanged = function(on) self:apply(model:setDropCap(on)) end, } for index = 0, #model.themes() - 1 do actions["theme_" .. index] = function() self:apply(model:setThemeIndex(index)) end diff --git a/apps/adventure-arena/controllers/SessionController.lua b/apps/adventure-arena/controllers/SessionController.lua index e910af7e..12d3f59d 100644 --- a/apps/adventure-arena/controllers/SessionController.lua +++ b/apps/adventure-arena/controllers/SessionController.lua @@ -90,6 +90,8 @@ function Controller:show(id, fresh) dictate = function() self:toggleDictation() end, close = function() self:close() end, readingSettings = function() self:showReadingSettings() end, + -- The reader's page no longer shows the compass; Compass.etlua + -- still binds to this when a page includes it. compassDrag = function(gesture) if type(gesture) ~= "table" or not self.refs then return end local direction = CompassGesture.direction(gesture.translation) @@ -193,17 +195,51 @@ function Controller:renderTranscript() font = settings.font, fontSize = settings.fontSize, lineSpacing = settings.lineSpacing, alignment = settings.alignment, primary = settings.primaryTextColor, secondary = settings.secondaryTextColor, rule = settings.ruleColor, + dropCap = settings.dropCap, } data.reveal = self:revealState(data.earlierEntries) - data.actions = {} + data.linkMenus, data.actions = self:linkMenus(data.entries) self.transcriptEarlier = data.earlierEntries return self.transcript:update(data) end +-- Links are live in the scene the reader stands in: the words of earlier +-- rooms name things that are no longer at hand, so they are set as plain +-- prose. Returns the menus keyed "entry_paragraph", as the Transcript +-- template's paragraph ids are, and the action each menu item runs. +function Controller:linkMenus(entries) + local menus, actions = {}, {} + local first = #entries + 1 + for index = #entries, 1, -1 do + first = index + if entries[index].kind == "scene" then break end + end + for index = first, #entries do + for paragraph, links in pairs(entries[index].links or {}) do + local menu = {} + for linkIndex, link in ipairs(links) do + local items = {} + for itemIndex, action in ipairs(self.model:linkActions(link.target)) do + local name = table.concat({ "link", index, paragraph, linkIndex, itemIndex }, "_") + actions[name] = function() self:submitCommand(action.command) end + table.insert(items, { title = action.title, action = name }) + end + if #items > 0 then + table.insert(menu, { + location = link.location, length = link.length, label = link.label, items = items, + }) + end + end + if #menu > 0 then menus[index .. "_" .. paragraph] = menu end + end + end + return menus, actions +end + -- ── Typing ────────────────────────────────────────────────────────────── -- New prose is rendered whole and revealed by `revealedCharacters`, so its -- lines never reflow as it types; paragraphs still waiting are hidden, and --- so is a whole entry (a chapter heading with it) until typing reaches it. The +-- so is a whole entry (a scene's title with it) until typing reaches it. The -- template describes the reveal at each render (a command or a reading -- settings change); between renders each tick writes the one paragraph -- being typed. @@ -355,8 +391,7 @@ function Controller:submitCommand(command) self:beginTyping(firstNew) local presentation = self.model:presentation() self.refs.progress.text = presentation.progress - self.refs.sessionPlace.text = presentation.chapterLabel ~= "" - and (presentation.chapterLabel .. " · " .. presentation.roomTitle) or presentation.roomTitle + self.refs.sessionPlace.text = presentation.roomTitle self.refs.input.text = "" self.refs.dictationStatus.text = "" self.refs.dictationStatus.hidden = true diff --git a/apps/adventure-arena/models/ReadingSettings.lua b/apps/adventure-arena/models/ReadingSettings.lua index 1f862b3a..a9dd1be2 100644 --- a/apps/adventure-arena/models/ReadingSettings.lua +++ b/apps/adventure-arena/models/ReadingSettings.lua @@ -48,6 +48,8 @@ function ReadingSettings.new(initial) theme = indexOf(THEMES, initial.theme) and initial.theme or THEMES[1].id, spacing = indexOf(SPACING, initial.spacing) and initial.spacing or "normal", justified = initial.justified == true, + -- A scene opens with a dropped initial unless the reader turns it off. + dropCap = initial.dropCap ~= false, }, ReadingSettings) self:setFontSize(initial.fontSize or SIZE.default) return self @@ -85,11 +87,16 @@ function ReadingSettings:setJustified(value) return true end +function ReadingSettings:setDropCap(value) + self.dropCap = value == true + return true +end + -- Plain values for persistence; `ReadingSettings.new(snapshot)` restores them. function ReadingSettings:snapshot() return { font = self.font, fontSize = self.fontSize, theme = self.theme, - spacing = self.spacing, justified = self.justified, + spacing = self.spacing, justified = self.justified, dropCap = self.dropCap, } end @@ -115,6 +122,7 @@ function ReadingSettings:presentation() spacingIndex = spacingIndex - 1, justified = self.justified, alignment = self.justified and "justified" or "leading", + dropCap = self.dropCap, theme = theme.id, themeIndex = themeIndex - 1, themes = themes, diff --git a/apps/adventure-arena/models/SavedGames.lua b/apps/adventure-arena/models/SavedGames.lua index 3be1c402..5050d6c5 100644 --- a/apps/adventure-arena/models/SavedGames.lua +++ b/apps/adventure-arena/models/SavedGames.lua @@ -2,8 +2,8 @@ local SavedGames = {} SavedGames.__index = SavedGames -- Autosaves, one per adventure: the command history and random seed that --- replay the story, plus what the library shows about it (chapter, room and --- score) without starting an engine. The store is injected, so the model +-- replay the story, plus what the library shows about it (room and score) +-- without starting an engine. The store is injected, so the model -- never touches files; `store.load()` returns the saved table and -- `store.save(table)` persists it. local VERSION = 1 diff --git a/apps/adventure-arena/models/Session.lua b/apps/adventure-arena/models/Session.lua index 8810d6e0..aafb3c4f 100644 --- a/apps/adventure-arena/models/Session.lua +++ b/apps/adventure-arena/models/Session.lua @@ -20,21 +20,8 @@ local DIRECTIONS = { -- reader keeps the most recent entries; the story itself is unaffected. local TRANSCRIPT = { limit = 120 } -local ROMAN = { - { 1000, "M" }, { 900, "CM" }, { 500, "D" }, { 400, "CD" }, { 100, "C" }, { 90, "XC" }, - { 50, "L" }, { 40, "XL" }, { 10, "X" }, { 9, "IX" }, { 5, "V" }, { 4, "IV" }, { 1, "I" }, -} - -function Session.roman(number) - local parts = {} - for _, pair in ipairs(ROMAN) do - while number >= pair[1] do - table.insert(parts, pair[2]) - number = number - pair[1] - end - end - return table.concat(parts) -end +-- A tapped word offers a handful of actions, never a wall of them. +local LINK = { actions = 6 } function Session.new(options) options = options or {} @@ -49,7 +36,7 @@ end function Session:reset() self.entries, self.history = {}, {} - self.moves, self.score, self.maxScore, self.chapters, self.scoreChange = 0, 0, 0, 0, 0 + self.moves, self.score, self.maxScore, self.scoreChange = 0, 0, 0, 0 self.availableDirections, self.exitList = {}, {} self.items, self.knownItems, self.knownByNoun = {}, {}, {} self.roomTitle, self.scene, self.openEntry = nil, nil, nil @@ -110,9 +97,10 @@ end -- ── Transcript structure ──────────────────────────────────────────────── -- The engine prints plain text. Books give the reader structure, so the --- transcript is parsed into a title-page banner, scenes (a new room opens a --- chapter whose first letter is set as a drop cap), the player's own commands, and --- narration. +-- transcript is parsed into a title-page banner, scenes (a new room opens +-- one under the room's name), the player's own commands, and narration. +-- Stories mark the words a reader can act on as "[[label]]" or +-- "[[label->target]]"; paragraphs keep the label and carry the links apart. local function trim(text) return (tostring(text or ""):gsub("^%s+", ""):gsub("%s+$", "")) @@ -154,12 +142,39 @@ local function splitFirstLine(paragraph) return trim(first), rest ~= "" and rest or nil end +-- Splits link markup from prose. Returns the text as the reader sees it and +-- its links, each { location, length, label, target }: `location` counts +-- characters from 0 as `utf8.len` does, and `target` is the parser's word +-- for the thing (the label itself when the story names no other). +function Session.parseLinks(text) + text = tostring(text or "") + local plain, links, position = {}, {}, 1 + local length = 0 + local function keep(piece) + table.insert(plain, piece) + length = length + (utf8.len(piece) or #piece) + end + while true do + local first, last, body = text:find("%[%[(.-)%]%]", position) + if not first then break end + keep(text:sub(position, first - 1)) + local label, target = body:match("^(.-)%->(.*)$") + label, target = trim(label or body), trim(target or body) + if label ~= "" and target ~= "" then + table.insert(links, { + location = length, length = utf8.len(label) or #label, + label = label, target = target:lower(), + }) + end + keep(label) + position = last + 1 + end + keep(text:sub(position)) + return table.concat(plain), links +end + function Session:beginScene(title) - self.chapters = self.chapters + 1 - local scene = { - kind = "scene", chapter = self.chapters, chapterLabel = "Chapter " .. Session.roman(self.chapters), - title = title, paragraphs = {}, - } + local scene = { kind = "scene", title = title, paragraphs = {}, links = {} } table.insert(self.entries, scene) self.scene, self.openEntry = scene, scene return scene @@ -167,10 +182,56 @@ end function Session:appendParagraph(paragraph) if not self.openEntry then - self.openEntry = { kind = "narration", paragraphs = {} } + self.openEntry = { kind = "narration", paragraphs = {}, links = {} } table.insert(self.entries, self.openEntry) end - table.insert(self.openEntry.paragraphs, paragraph) + local text, links = Session.parseLinks(paragraph) + table.insert(self.openEntry.paragraphs, text) + self.openEntry.links[#self.openEntry.paragraphs] = links +end + +local function capitalized(text) + return (text:gsub("^%l", string.upper)) +end + +-- What a reader can do with a linked word, as { title, command } in the +-- order they are likeliest to want. A direction walks; an object offers +-- "examine" and the verbs the story accepts for it, where those need no +-- second object. The command uses the story's own word for the thing. +function Session:linkActions(target) + target = trim(target):lower() + if target == "" then return {} end + local direction = DIRECTIONS[target] + if direction then + return { { title = "Go " .. direction, command = direction } } + end + local words = {} + for word in target:gmatch("[%w']+") do words[word] = true end + local noun = Suggestions.noun(target) + local function find(items) + for _, item in ipairs(items) do + if item.noun == noun then return item end + end + for _, item in ipairs(items) do + for word in item.name:lower():gmatch("[%w']+") do + if words[word] then return item end + end + end + end + local item = find(self.items) or find(self.knownItems) + local verbs, seen = { "examine" }, { examine = true, look = true } + for _, verb in ipairs(item and Suggestions.oneTapVerbs(item) or {}) do + if not seen[verb] then + seen[verb] = true + table.insert(verbs, verb) + end + end + local actions = {} + for index = 1, math.min(#verbs, LINK.actions) do + local command = verbs[index] .. " " .. target + table.insert(actions, { title = capitalized(command), command = command }) + end + return actions end function Session:appendOutput(text, openingTitle) @@ -185,7 +246,7 @@ function Session:appendOutput(text, openingTitle) end -- A move without a printed heading still enters a new room: the scene -- opens at the first paragraph that is not the title page. - -- The opening always starts Chapter I, even when the engine names no room. + -- The opening always starts a scene, even when the engine names no room. local pendingRoom = not headingFound and (room or openingTitle) and (not self.scene or self.scene.title ~= (room or openingTitle)) and (room or openingTitle) or nil @@ -217,7 +278,7 @@ end -- `saved` is a snapshot from `Session:snapshot()`. The story is restored by -- replaying its commands against an engine seeded as before, which rebuilds --- the chapters exactly as the reader first saw them. +-- the transcript exactly as the reader first saw it. function Session:start(game, saved) if not game then return false, "Adventure not found." end if not self.engineFactory then return false, "No session engine configured." end @@ -262,7 +323,7 @@ function Session:snapshot() for _, command in ipairs(self.history) do table.insert(commands, command) end return { gameId = game.id, seed = self.seed, commands = commands, - chapter = self.chapters, room = self.roomTitle or (self.scene and self.scene.title) or game.title, + room = self.roomTitle or (self.scene and self.scene.title) or game.title, score = self.score, maxScore = self.maxScore, moves = self.moves, } end @@ -329,7 +390,6 @@ function Session:presentation() ink = game.ink or game.tint or "accent", initialFont = game.initialFont, roomTitle = self.roomTitle or (scene and scene.title) or game.title or "", - chapterLabel = scene and scene.chapterLabel or "", entries = entries, earlierEntries = earlier, score = self.score, diff --git a/apps/adventure-arena/models/Suggestions.lua b/apps/adventure-arena/models/Suggestions.lua index 1d70c1a1..54f4338d 100644 --- a/apps/adventure-arena/models/Suggestions.lua +++ b/apps/adventure-arena/models/Suggestions.lua @@ -92,6 +92,22 @@ local function primaryVerb(item) return best or "examine" end +-- The verbs the story accepts for an item that make whole commands on +-- their own, best known first; verbs outside the common set keep the +-- story's order after them. +function Suggestions.oneTapVerbs(item) + local verbs = {} + for index, verb in ipairs(item.verbs or {}) do + if not NEEDS_SECOND[verb] then + table.insert(verbs, { verb = verb, rank = VERB_RANK[verb] or (#VERBS + index) }) + end + end + table.sort(verbs, function(a, b) return a.rank < b.rank end) + local names = {} + for _, entry in ipairs(verbs) do table.insert(names, entry.verb) end + return names +end + local function tokenize(input) local words = {} for word in input:lower():gmatch("%S+") do table.insert(words, word) end diff --git a/apps/adventure-arena/services/ZILRuntime.lua b/apps/adventure-arena/services/ZILRuntime.lua index 06261caa..6eb53925 100644 --- a/apps/adventure-arena/services/ZILRuntime.lua +++ b/apps/adventure-arena/services/ZILRuntime.lua @@ -66,6 +66,8 @@ function ZILRuntime.new(game, readFile, seed) local runtime = require("zilscript.runtime") local env = runtime.create_game_env() env.rawget, env.rawset, env.rawequal = rawget, rawset, rawequal + -- The reader renders the story's link markup; see Session.parseLinks. + env.PROSE_LINKS = true env.math = setmetatable({ random = ZILRuntime.random(seed or os.time()), randomseed = function() end }, { __index = math }) local bootstrapPath = "apps/adventure-arena/zilscript/zilscript/bootstrap.lua" diff --git a/apps/adventure-arena/views/ProseLinks.etlua b/apps/adventure-arena/views/ProseLinks.etlua new file mode 100644 index 00000000..00e9c161 --- /dev/null +++ b/apps/adventure-arena/views/ProseLinks.etlua @@ -0,0 +1,10 @@ +<% + -- The words of a paragraph the reader can act on, and what each offers. +%> +<% for _, link in ipairs(links or {}) do %> + + <% for _, item in ipairs(link.items) do %> + + <% end %> + +<% end %> diff --git a/apps/adventure-arena/views/ReadingOptions.etlua b/apps/adventure-arena/views/ReadingOptions.etlua index 8ec22a0a..0d33644e 100644 --- a/apps/adventure-arena/views/ReadingOptions.etlua +++ b/apps/adventure-arena/views/ReadingOptions.etlua @@ -1,6 +1,6 @@ <% -- Apple Books’ "Themes & Settings": type size, page themes shown as pages, - -- the face, the leading and justification. Used by the in-book sheet and the + -- the face, the leading, justification and drop caps. Used by the in-book sheet and the -- Settings tab; `preview` adds a sample page for when no book is behind it. local SWATCH = { width = 74, height = 92, radius = 16, sample = 26 } %> @@ -50,13 +50,13 @@