local module = {}
setmetatable(module, {__index=_G})
setfenv(1, module)
local m = require 'marigold-svg.marigold'
function capitalize(str)
return string.gsub(str, "^.", string.upper)
end
--===== misc =====--
local function stack(...)
local array = {...}
local l
l = function(acc_f, acc_h)
if #array == 0 then return acc_f, acc_h end
local f, h = array[1][1], array[1][2]
table.remove(array, 1)
local acc_f = function(y)
local tbl = acc_f(y)
table.insert(tbl, f(y+acc_h))
return tbl
end
local acc_h = acc_h + h
return l(acc_f, acc_h)
end
local f, h = l(function(y) return {} end, 0)
return function(y) return m.g(f(y)) end, h
end
local function pad(top, bottom, f, h)
return function(y) return f(y+top) end, top + h + bottom
end
local function _divider(theme)
local size = 3
return function(y) return m.rect{
x=0, y=y, width=500, height=size,
style="fill:"..theme.flair,
} end, size
end
local divider = function(theme) return pad(0, 10, _divider(theme)) end
local function empty()
return function(y) return m.g{} end, 0
end
local function wrap_text(str, max_len, tbl)
tbl = tbl or {}
local len = 0
local count = 0
local in_tag = false
for i = 1,#str do
count = count+1
local c = string.sub(str, i, i)
if c == '>' then in_tag = false
elseif c == '<' then in_tag = true
end
if not in_tag then len = len+1 end
if len >= max_len then break end
end
local line = string.sub(str, 1,count)
if len < max_len then
table.insert(tbl, line)
return tbl
end
-- remove word-end from line
local start = string.find(line, '[^ ]+$')
local word = string.sub(line, start or #line)
local line = string.sub(line, 1, (start and start-1) or nil)
local rest = word .. string.sub(str, count+1)
table.insert(tbl, line)
return wrap_text(rest, max_len, tbl)
end
local function wrapped_text(str, max_len, x, size, line_spacing, style)
local lines = wrap_text(str, max_len)
local transform = function(index, line)
return function(y) return m.text(line, {
x=x, y=y+size,
style="font-size:"..size.."px;"..(style or ''),
textLength=(index ~= #lines and 480) or nil,
}) end, size+line_spacing
end
local tbl = {}
for index, line in ipairs(lines) do
table.insert(tbl, {transform(index, line)})
end
return stack(unpack(tbl))
end
local function format(str)
local str = string.gsub(str, "%*%*(.-)%*%*", '%1')
str = string.gsub(str, "%*(.-)%*", '%1')
return str
end
local function text(str, size, style)
return function(y) return m.text(
str,
{ style="font-size:"..size.."px;" .. (style or ''), x=10, y=y+size }
) end, size
end
--===== basic stats =====--
local function header(stats, theme)
local f, h = stack(
--{pad(10, 10, name(stats))},
{pad(10, 10, text(
stats.name, 40,
'font-variant-caps:small-caps;stroke:'..theme.text..';fill:'..theme.flair..';font-family:serif'
))},
{pad(0, 10, text(
string.format("%s %s, %s", capitalize(stats.size), stats.type, stats.alignment),
14, 'font-style:italic;fill:'..theme.text
))}
)
return f, h
end
local function statline(name, value, theme)
return pad(0, 2, wrapped_text(
string.format('%s %s', name, value),
80, 10, 14, 2, 'fill:'..theme.flair
))
end
local function armor_hp(stats, theme)
local f, h = pad(0, 10, stack(
{statline('Armor Class', stats.ac, theme)},
{statline('Hit Points', stats.hp, theme)},
{statline('Speed', stats.speed, theme)}
))
return f, h
end
local function bonus(ability_score)
return math.floor( (ability_score-10)/2 )
end
local function scorebox(name, ability_score, x, y, theme)
local score = tonumber(ability_score)
local value = string.format("%d (%s%d)", score, (score<0 and '-') or '+', bonus(score))
return m.g{
m.text(name, { x=x+10, y=y+15, style="font-weight:bold;font-size:15px;fill:"..theme.flair }),
m.text(value, {x=x, y=y+30, style="font-size:15px;fill:"..theme.flair }),
}
end
local function ability_scores(stats, theme)
return function(y) return m.g{
scorebox('STR', stats.str, 10, y, theme),
scorebox('DEX', stats.dex, 90, y, theme),
scorebox('CON', stats.con, 170, y, theme),
scorebox('INT', stats.int, 250, y, theme),
scorebox('WIS', stats.wis, 330, y, theme),
scorebox('CHA', stats.cha, 410, y, theme),
} end, 40
end
local function optional_attribute(stats, name, key, theme)
local value = stats[key]
if value then
local f, h = statline(name, value, theme)
return f, h
else
local f, h = empty()
return f, h
end
end
local function misc_attributes(stats, theme)
local f, h = stack(
{optional_attribute(stats, 'Saving Throws', 'saving_throws', theme)},
{optional_attribute(stats, 'Skills', 'skills', theme)},
{optional_attribute(stats, 'Damage Vulnerabilities', 'vulnerabilities', theme)},
{optional_attribute(stats, 'Damage Resistances', 'resistances', theme)},
{optional_attribute(stats, 'Damage Immunities', 'immunities', theme)},
{optional_attribute(stats, 'Condition Immunities', 'condition_immunities', theme)},
{optional_attribute(stats, 'Senses', 'senses', theme)},
{optional_attribute(stats, 'Languages', 'languages', theme)},
{optional_attribute(stats, 'Challenge', 'cr', theme)}
)
if h > 0 then return stack({pad(0, 10, f, h)}, {divider(theme)})
else return empty(), 0 end
end
local function base(stats, theme)
local f, h = stack(
{header(stats, theme)},
{divider(theme)},
{armor_hp(stats, theme)},
{divider(theme)},
{ability_scores(stats, theme)},
{divider(theme)},
{misc_attributes(stats, theme)}
)
return f, h
end
--===== traits =====--
local function trait(t, theme)
return pad(0, 10, wrapped_text(
string.format('%s. %s', t.name, format(t.value)),
80, 10, 14, 5, 'fill:'..theme.text
))
end
local function traits(stats, theme)
if not stats.traits then return empty() end
local tbl = {}
for _, t in ipairs(stats.traits) do
table.insert(tbl, {trait(t, theme)})
end
return stack(unpack(tbl))
end
--===== actions =====--
local function subheader(title, theme)
return pad(0, 10, stack(
{pad(0, 5, text(title, 25, 'font-variant-caps:small-caps;stroke:'..theme.text..';fill:'..theme.flair))},
{divider(theme)}
))
end
local function actions(stats, theme)
if not stats.actions then return empty() end
local tbl = {}
for _, action in ipairs(stats.actions) do
table.insert(tbl, {trait(action, theme)})
end
return stack(
{subheader('Actions', theme)},
unpack(tbl)
)
end
local function reactions(stats, theme)
if not stats.reactions then return empty() end
local tbl = {}
for _, reaction in ipairs(stats.reactions) do
table.insert(tbl, {trait(reaction, theme)})
end
return stack(
{subheader('Reactions', theme)},
unpack(tbl)
)
end
local function legendary(stats, theme)
if not stats.legendary then return empty() end
local tbl = {}
for _, action in ipairs(stats.legendary.actions) do
table.insert(tbl, {trait(action, theme)})
end
return stack(
{subheader('Legendary Actions', theme)},
{pad(0, 10, wrapped_text(stats.legendary.description, 80, 10, 14, 2, 'fill:'..theme.text))},
unpack(tbl)
)
end
--===== draw =====--
function draw(stats, theme)
local theme = theme or {
bg = '#020202',
text = 'beige',
flair = 'tomato',
}
local f, h = stack(
{base(stats, theme)},
{traits(stats, theme)},
{actions(stats, theme)},
{reactions(stats, theme)},
{legendary(stats, theme)}
)
return m.render(m.svg{
viewBox = string.format("0 0 500 %d", h),
width = 500,
height = height,
style = "font-family:sans-serif;",
m.rect{x=0, y=0, width=500, height=h, style='fill:'..theme.bg},
f(0),
})
end
return module