Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions apps/adventure-arena/controllers/LibraryController.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 39 additions & 4 deletions apps/adventure-arena/controllers/SessionController.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion apps/adventure-arena/models/ReadingSettings.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions apps/adventure-arena/models/SavedGames.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
120 changes: 90 additions & 30 deletions apps/adventure-arena/models/Session.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand All @@ -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
Expand Down Expand Up @@ -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+$", ""))
Expand Down Expand Up @@ -154,23 +142,96 @@ 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
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)
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions apps/adventure-arena/models/Suggestions.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/adventure-arena/services/ZILRuntime.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions apps/adventure-arena/views/ProseLinks.etlua
Original file line number Diff line number Diff line change
@@ -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 %>
<Hyperlink location="<%= link.location %>" length="<%= link.length %>" label="<%= link.label %>">
<% for _, item in ipairs(link.items) do %>
<MenuItem title="<%= item.title %>" action="<%= item.action %>" />
<% end %>
</Hyperlink>
<% end %>
8 changes: 4 additions & 4 deletions apps/adventure-arena/views/ReadingOptions.etlua
Original file line number Diff line number Diff line change
@@ -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 }
%>
Expand Down Expand Up @@ -50,13 +50,13 @@
<Option title="Relaxed" />
</Picker>
<Toggle id="justifyToggle" label="Justify Text" value="<%= justified and 'true' or 'false' %>" onChange="justifyChanged" maxWidth="infinity" />
<Toggle id="dropCapToggle" label="Drop Caps" value="<%= dropCap and 'true' or 'false' %>" onChange="dropCapChanged" maxWidth="infinity" />
</VStack>

<% if preview then %>
<VStack id="preview" padding="20" spacing="0" alignment="leading" background="<%= pageColor %>" cornerRadius="22" clipsToBounds="true" maxWidth="infinity">
<Label text="Chapter I" size="<%= fontSize - 3 %>" weight="semibold" color="systemIndigo|systemIndigo" smallCaps="true" />
<Label text="West of House" size="<%= fontSize + 6 %>" weight="semibold" design="serif" color="<%= primaryTextColor %>" paddingTop="2" paddingBottom="<%= math.floor(fontSize * 0.7) %>" />
<Paragraph id="previewBody" text="You are standing in an open field west of a white house, with a boarded front door. There is a small mailbox here." size="<%= fontSize %>" design="<%= font %>" color="<%= primaryTextColor %>" lineSpacing="<%= lineSpacing %>" alignment="<%= alignment %>" hyphenation="<%= justified and 'true' or 'false' %>" dropCap="true" dropCapFontName="SnellRoundhand-Bold" dropCapColor="systemIndigo" selectable="false" />
<Label text="West of House" size="<%= fontSize + 6 %>" weight="semibold" design="serif" color="<%= primaryTextColor %>" paddingBottom="<%= math.floor(fontSize * 0.7) %>" />
<Paragraph id="previewBody" text="You are standing in an open field west of a white house, with a boarded front door. There is a small mailbox here." size="<%= fontSize %>" design="<%= font %>" color="<%= primaryTextColor %>" lineSpacing="<%= lineSpacing %>" alignment="<%= alignment %>" hyphenation="<%= justified and 'true' or 'false' %>" dropCap="<%= dropCap and 'true' or 'false' %>" dropCapFontName="SnellRoundhand-Bold" dropCapColor="systemIndigo" selectable="false" />
</VStack>
<% end %>
</VStack>
Loading