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()
local size = 3
return function(y) return m.rect{
x=0, y=y, width=500, height=size,
style="fill:darkred;",
} end, size
end
local divider = function() return pad(0, 10, _divider()) 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)
local f, h = stack(
--{pad(10, 10, name(stats))},
{pad(10, 10, text(
stats.name, 40,
'font-variant-caps:small-caps;stroke:black;fill:darkred;font-family:serif'
))},
{pad(0, 10, text(
string.format("%s %s, %s", capitalize(stats.size), stats.type, stats.alignment),
14, 'font-style:italic'
))}
)
return f, h
end
local function _statline(name, value)
local size = 14
return function(y) return m.text(
''..name..' '..value,
{ x=10, y=y+size, style="font-size:"..size.."px;fill:darkred;" }
) end, 14
end
--local statline = function(name, value) return pad(0, 2, _statline(name, value)) end
local function statline(name, value)
return pad(0, 2, wrapped_text(
string.format('%s %s', name, value),
80, 10, 14, 2, 'fill:darkred'
))
end
local function armor_hp(stats)
local f, h = pad(0, 10, stack(
{statline('Armor Class', stats.ac)},
{statline('Hit Points', stats.hp)},
{statline('Speed', stats.speed)}
))
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)
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:darkred" }),
m.text(value, {x=x, y=y+30, style="font-size:15px;fill:darkred;" }),
}
end
local function ability_scores(stats)
return function(y) return m.g{
scorebox('STR', stats.str, 10, y),
scorebox('DEX', stats.dex, 90, y),
scorebox('CON', stats.con, 170, y),
scorebox('INT', stats.int, 250, y),
scorebox('WIS', stats.wis, 330, y),
scorebox('CHA', stats.cha, 410, y),
} end, 40
end
local function optional_attribute(stats, name, key)
local value = stats[key]
if value then
local f, h = statline(name, value)
return f, h
else
local f, h = empty()
return f, h
end
end
local function misc_attributes(stats)
local f, h = stack(
{optional_attribute(stats, 'Saving Throws', 'saving_throws')},
{optional_attribute(stats, 'Skills', 'skills')},
{optional_attribute(stats, 'Damage Vulnerabilities', 'vulnerabilities')},
{optional_attribute(stats, 'Damage Resistances', 'resistances')},
{optional_attribute(stats, 'Damage Immunities', 'immunities')},
{optional_attribute(stats, 'Condition Immunities', 'condition_immunities')},
{optional_attribute(stats, 'Senses', 'senses')},
{optional_attribute(stats, 'Languages', 'languages')},
{optional_attribute(stats, 'Challenge', 'cr')}
)
if h > 0 then return stack({pad(0, 10, f, h)}, {divider()})
else return empty(), 0 end
end
local function base(stats)
local f, h = stack(
{header(stats)},
{divider()},
{armor_hp(stats)},
{divider()},
{ability_scores(stats)},
{divider()},
{misc_attributes(stats)}
)
return f, h
end
--===== traits =====--
local function trait(t)
return pad(0, 10, wrapped_text(
string.format('%s. %s', t.name, format(t.value)),
80, 10, 14, 5
))
end
local function traits(stats)
if not stats.traits then return empty() end
local tbl = {}
for _, t in ipairs(stats.traits) do
table.insert(tbl, {trait(t)})
end
return stack(unpack(tbl))
end
--===== actions =====--
local function subheader(title)
return pad(0, 10, stack(
{pad(0, 5, text(title, 25, 'font-variant-caps:small-caps;stroke:black;fill:darkred'))},
{divider()}
))
end
local function actions(stats)
if not stats.actions then return empty() end
local tbl = {}
for _, action in ipairs(stats.actions) do
table.insert(tbl, {trait(action)})
end
return stack(
{subheader('Actions')},
unpack(tbl)
)
end
local function reactions(stats)
if not stats.reactions then return empty() end
local tbl = {}
for _, reaction in ipairs(stats.reactions) do
table.insert(tbl, {trait(reaction)})
end
return stack(
{subheader('Reactions')},
unpack(tbl)
)
end
local function legendary(stats)
if not stats.legendary then return empty() end
local tbl = {}
for _, action in ipairs(stats.legendary.actions) do
table.insert(tbl, {trait(action)})
end
return stack(
{subheader('Legendary Actions')},
{pad(0, 10, wrapped_text(stats.legendary.description, 80, 10, 14, 2, ''))},
unpack(tbl)
)
end
--===== draw =====--
function draw(stats)
local f, h = stack(
{base(stats)},
{traits(stats)},
{actions(stats)},
{reactions(stats)},
{legendary(stats)}
)
return m.render(m.svg{
viewBox = string.format("0 0 500 %d", h),
width = 500,
height = height,
style = "font-family:sans-serif;",
f(0),
})
end
return module