Files
ChaosiumCon26/apps/ringworld-character-sheets/build_v2.py
T
timevans666 0a11f611de Ringworld sheets v2.1 — weapons wrap, armour column, no back-page equipment
WEAPONS — damage column word-wrap:
  Before: character-count split at 14 chars (broke 'paralytic' → 'paralyti/c')
  After: textwrap.wrap() at word boundaries, width=17
  Long values: two lines, both at correct baselines with proper spacing

BACK PAGE — equipment removed:
  Before: equipment listed again (solid black block, white text) — duplicate of front
  After: full-width white SESSION NOTES area with ruled lines for writing

ARMOUR COLUMN — now populated from character data:
  Yashti Korr: Chest AP 2, Abdomen AP 2 (body armour torso)
  RR'raan: AP 1 all locations (natural Kzin hide)
  Tomas Veld: AP 1 all locations (hard environment suit)
  Dr. Mira Chen: no armour
  Whisper-of-Distant-Worlds: no armour
  Sola Reyes: no armour
  Armour cells: green tint when protection present, white when none
2026-05-19 16:32:41 +00:00

1240 lines
59 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Ringworld RPG Character Sheets — ChaosiumCon UK 2026
Events 95 & 96 — Crew of the Foolish Endeavour
Built with reportlab for precise print-ready PDF output.
"""
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas as rl_canvas
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.lib.colors import HexColor, white, black, Color
import math, os
# ─── Constants ────────────────────────────────────────────────────────────────
PW, PH = A4 # 595.28 × 841.89 pt
PAD = 6*mm # page margin
# ─── Font registration ────────────────────────────────────────────────────────
pdfmetrics.registerFont(TTFont('Orb', '/tmp/font-orbitron-700.ttf'))
pdfmetrics.registerFont(TTFont('OrbB', '/tmp/font-orbitron-900.ttf'))
pdfmetrics.registerFont(TTFont('Mono', '/tmp/font-sharetechmono.ttf'))
pdfmetrics.registerFont(TTFont('Bar', '/tmp/font-barlow-700.ttf'))
pdfmetrics.registerFont(TTFont('BarB', '/tmp/font-barlow-900.ttf'))
# ─── Colour helpers ───────────────────────────────────────────────────────────
INK = HexColor('#06060e')
LIGHT = HexColor('#f0f0f8')
MID = HexColor('#c0c0d8')
PALE = HexColor('#e8e8f4')
# ─── Skill base chances (from Explorer Book) ─────────────────────────────────
BASE = {
# AGILITY
'Archaic Melee Weapon':5,'Archaic Ranged Weapons':3,'Athletics':15,
'Hide':10,'Sneak':5,'Unarmed Combat':0,'V.Sword / F.Laser':15,
# COMMUNICATION
'Bargain':10,'Debate':5,'Fast Talk':10,'Fine Arts':5,'Musicianship':5,
'Orate':5,'Own Language':90,'Perform':5,'Psychology':0,
# KNOWLEDGE
'Anthropology':0,'Astronomy':0,'Biology':0,'Botany':0,'Chemistry':0,
'Computers':0,'Emergency Treatment':1,'Engineering':0,'Farming':0,
'History':5,'Law':0,'Mathematics':0,'Physics':0,'Planetology':0,
'Second Language':0,'Strategy':0,'Theology':0,'Zoology':0,
# PERCEPTION
'Handgun Energy':5,'Handgun Projectile':3,
'Heavy Weapon Energy':5,'Heavy Weapon Proj.':3,
'Listen':5,'Observe':5,'Scent':0,'Search':5,'Track':5,
# TECHNICAL
'Aquatic Vehicle':0,'Atmospheric Craft':0,'Ground Vehicle':0,
'Hyperdrive':0,'Personal Flyer':15,'Reaction Drive':0,
'Reactionless Drive':0,'Repair':0,'Ringworld':0,'Weapons System':0,
}
SKILL_CATEGORIES = [
('AGILITY', ['Archaic Melee Weapon','Archaic Ranged Weapons','Athletics',
'Hide','Sneak','Unarmed Combat','V.Sword / F.Laser']),
('COMMUNICATION', ['Bargain','Debate','Fast Talk','Fine Arts','Musicianship',
'Orate','Own Language','Perform','Psychology']),
('KNOWLEDGE', ['Anthropology','Astronomy','Biology','Botany','Chemistry',
'Computers','Emergency Treatment','Engineering','Farming',
'History','Law','Mathematics','Physics','Planetology',
'Second Language','Strategy','Theology','Zoology']),
('PERCEPTION', ['Handgun Energy','Handgun Projectile','Heavy Weapon Energy',
'Heavy Weapon Proj.','Listen','Observe','Scent','Search','Track']),
('TECHNICAL', ['Aquatic Vehicle','Atmospheric Craft','Ground Vehicle',
'Hyperdrive','Personal Flyer','Reaction Drive',
'Reactionless Drive','Repair','Ringworld','Weapons System']),
]
def get_skill(char_skills, name):
"""Return trained value or base chance."""
if name in char_skills:
return char_skills[name]
return BASE.get(name, 0)
# ─── Drawing helpers ──────────────────────────────────────────────────────────
def filled_rect(c, x, y, w, h, fill, stroke=None, lw=0):
c.saveState()
c.setFillColor(fill)
if stroke:
c.setStrokeColor(stroke)
c.setLineWidth(lw if lw else 0.5)
else:
c.setLineWidth(0)
c.rect(x, y, w, h, fill=1, stroke=1 if stroke else 0)
c.restoreState()
def outline_rect(c, x, y, w, h, stroke, lw=1):
c.saveState()
c.setStrokeColor(stroke)
c.setLineWidth(lw)
c.setFillColor(Color(0,0,0,0))
c.rect(x, y, w, h, fill=0, stroke=1)
c.restoreState()
def text(c, txt, x, y, font, size, color, align='left'):
c.saveState()
c.setFont(font, size)
c.setFillColor(color)
if align=='left': c.drawString(x, y, txt)
elif align=='right': c.drawRightString(x, y, txt)
elif align=='centre': c.drawCentredString(x, y, txt)
c.restoreState()
def hline(c, x1, x2, y, color, lw=0.5):
c.saveState()
c.setStrokeColor(color)
c.setLineWidth(lw)
c.line(x1, y, x2, y)
c.restoreState()
def dots(c, x1, x2, y, color):
"""Draw dotted fill between two x positions."""
c.saveState()
c.setFillColor(color)
step = 3
for px in range(int(x1), int(x2), step):
c.circle(px, y+1, 0.4, fill=1, stroke=0)
c.restoreState()
# ─── Section label helper ─────────────────────────────────────────────────────
def section_label(c, x, y, w, label, acc, font='OrbB', size=7):
filled_rect(c, x, y, w, 10, acc)
text(c, label, x+4, y+2.5, font, size, white)
# ─── HP tick boxes ────────────────────────────────────────────────────────────
def draw_hp_boxes(c, x, y, n, acc, box=5.5, gap=1.5):
c.saveState()
c.setStrokeColor(acc)
c.setLineWidth(1)
cx = x
for i in range(n):
c.setFillColor(Color(0,0,0,0))
c.rect(cx, y, box, box, fill=0, stroke=1)
if (i+1) % 5 == 0:
# tick mark above every 5th
c.setStrokeColor(acc)
c.line(cx+box/2, y+box+1, cx+box/2, y+box+3)
cx += box + gap
c.restoreState()
return cx # end x position
# ─── Front page ───────────────────────────────────────────────────────────────
def draw_front(c, char, page_num, total_pages):
A = char['acc'] # accent colour
AD = char['acc_dark'] # dark accent
# ── HEADER BAND ──────────────────────────────────────────────────────────
# Full-width colour band at top
header_h = 42*mm
filled_rect(c, 0, PH-header_h, PW, header_h, A)
# Character number (huge watermark)
c.saveState()
c.setFont('OrbB', 120)
c.setFillColor(white)
c.setFillAlpha(0.08)
c.drawString(8*mm, PH-header_h+2*mm, str(char['num']))
c.restoreState()
# Portrait slot (right side, 40mm × 42mm)
port_w = 40*mm
port_h = header_h
port_x = PW - port_w
port_y = PH - header_h
# Portrait image or placeholder
if char.get('portrait_path') and os.path.exists(char['portrait_path']):
try:
c.drawImage(char['portrait_path'], port_x, port_y,
width=port_w, height=port_h,
preserveAspectRatio=True, anchor='n', mask='auto')
except:
filled_rect(c, port_x, port_y, port_w, port_h, AD)
else:
filled_rect(c, port_x, port_y, port_w, port_h, AD)
# Placeholder crosshair
cx, cy = port_x + port_w/2, port_y + port_h/2
c.saveState()
c.setStrokeColor(A)
c.setLineWidth(0.8)
c.setStrokeAlpha(0.4)
c.line(cx-8*mm, cy, cx+8*mm, cy)
c.line(cx, cy-8*mm, cx, cy+8*mm)
c.rect(cx-10*mm, cy-10*mm, 20*mm, 20*mm, fill=0, stroke=1)
c.restoreState()
text(c, 'PORTRAIT', cx, cy-14*mm, 'Mono', 6, A, align='centre')
text(c, 'TBD', cx, cy-17*mm, 'Mono', 5, A, align='centre')
# Dark overlay strip behind text (left of portrait)
name_area_w = PW - port_w - 2*PAD
# Species badge
filled_rect(c, PAD, PH - 8*mm, 28*mm, 5.5*mm, INK)
text(c, char['species'].upper(), PAD+2*mm, PH-6.5*mm, 'Mono', 6, A)
# Character name
name_y = PH - header_h + 16*mm
text(c, char['name'].upper(), PAD, name_y, 'OrbB', 18, white)
# Role
text(c, char['role'], PAD, name_y - 6*mm, 'Bar', 8.5, white)
# Homeworld / context
text(c, char.get('context',''), PAD, name_y - 11*mm, 'Mono', 6.5, HexColor('#ffffffaa'))
# RINGWORLD masthead - very top strip
filled_rect(c, 0, PH-3.5*mm, PW, 3.5*mm, INK)
text(c, 'RINGWORLD', PAD, PH-2.8*mm, 'OrbB', 7, A)
text(c, 'EXPLORER BOOK · CHAOSIUMCON UK 2026 · EVENTS 95 & 96',
PW/2, PH-2.8*mm, 'Mono', 5.5, MID, align='centre')
text(c, f'{page_num}/{total_pages}', PW-PAD, PH-2.8*mm, 'Mono', 5.5, MID, align='right')
# Colour bar at bottom of header
filled_rect(c, 0, PH-header_h-1.5*mm, PW, 1.5*mm, INK)
# ── CHARACTERISTICS ───────────────────────────────────────────────────────
stats_y = PH - header_h - 1.5*mm
stats_h = 26*mm
filled_rect(c, 0, stats_y - stats_h, PW, stats_h, PALE)
stats = [('STR',char['STR']),('CON',char['CON']),('SIZ',char['SIZ']),
('INT',char['INT']),('POW',char['POW']),('DEX',char['DEX']),
('APP',char['APP']),('EDU',char['EDU'])]
box_w = (PW - 2*PAD) / 8
for i, (lbl, val) in enumerate(stats):
bx = PAD + i * box_w
by = stats_y - stats_h + 1*mm
bh = stats_h - 2*mm
bw = box_w - 1.5
# box
outline_rect(c, bx, by, bw, bh, A, lw=1.2)
# label
text(c, lbl, bx + bw/2, by + bh - 7, 'OrbB', 9, A, align='centre')
# value
text(c, str(val), bx + bw/2, by + 4, 'OrbB', 23, INK, align='centre')
# ── DERIVED STATS ROW ─────────────────────────────────────────────────────
der_y = stats_y - stats_h
der_h = 12*mm
filled_rect(c, 0, der_y - der_h, PW, der_h, HexColor('#eeeef8'))
hline(c, 0, PW, der_y - der_h, A, lw=1.5)
hp = char['hp']
dex = int(char['DEX'])
con = int(char['CON'])
intv = int(char['INT'])
powv = int(char['POW'])
ar_table = {range(1,5):7, range(5,9):6, range(9,13):5,
range(13,17):4, range(17,21):3, range(21,25):2}
ar = next((v for k,v in ar_table.items() if dex in k), 1)
unc = math.ceil(hp * 0.25)
derived = [
('HP MAX', str(hp)),
('MOVE', char.get('move','3m')),
('DAM BONUS', char['db']),
('ACT RANK', str(ar)),
('DODGE', f"{dex*3}%"),
('HEALTH', f"{con*3}%"),
('REASONING', f"{intv*3}%"),
('LUCK', f"{powv*3}%"),
('UNCONSCIOUS', f"≤{unc}HP"),
]
dbox_w = (PW - 2*PAD) / len(derived)
for i, (lbl, val) in enumerate(derived):
dx = PAD + i * dbox_w
dy = der_y - der_h + 1*mm
dh = der_h - 2*mm
dw = dbox_w - 1
# Highlight HP MAX
fill = A if lbl == 'HP MAX' else HexColor('#f8f8ff')
outline_rect(c, dx, dy, dw, dh, A, lw=0.8)
filled_rect(c, dx+0.4, dy+0.4, dw-0.8, dh-0.8, fill)
lbl_col = INK if lbl == 'HP MAX' else A
val_col = INK if lbl == 'HP MAX' else INK
text(c, lbl, dx + dw/2, dy + dh - 5, 'Orb', 4.8, lbl_col, align='centre')
text(c, val, dx + dw/2, dy + 3.5, 'OrbB', 9, val_col, align='centre')
# ── HP TRACKER ────────────────────────────────────────────────────────────
hp_y = der_y - der_h
hp_sec_h = 10*mm
filled_rect(c, 0, hp_y - hp_sec_h, PW, hp_sec_h, white)
hline(c, 0, PW, hp_y - hp_sec_h, A, lw=1.5)
text(c, 'HIT POINTS', PAD, hp_y - 4*mm, 'OrbB', 6.5, A)
draw_hp_boxes(c, PAD + 26*mm, hp_y - hp_sec_h + 2.5*mm, hp, A)
text(c, f'UNCONSCIOUS ≤ {unc} · DYING at 0 · DEAD at −{con}',
PW - PAD, hp_y - hp_sec_h + 3*mm, 'Mono', 5.5, HexColor('#444466'), align='right')
# ── SKILLS SECTION ────────────────────────────────────────────────────────
sk_top = hp_y - hp_sec_h
wpn_h = 40*mm # weapons
notes_h = 34*mm # equipment + personality
foot_h = 8*mm # footer
sk_bot = wpn_h + notes_h + foot_h
sk_h = sk_top - sk_bot
filled_rect(c, 0, sk_bot, PW, sk_h, white)
hline(c, 0, PW, sk_bot + sk_h, A, lw=1.5)
# Three skill columns
col_w = (PW - 2*PAD - 2*3) / 3 # 3 columns, 3pt gaps
col_xs = [PAD, PAD + col_w + 3, PAD + 2*(col_w + 3)]
# Column assignment
col_cats = [
[SKILL_CATEGORIES[0], SKILL_CATEGORIES[1]], # Agility + Communication
[SKILL_CATEGORIES[2]], # Knowledge
[SKILL_CATEGORIES[3], SKILL_CATEGORIES[4]], # Perception + Technical
]
# Draw column dividers
for cx in col_xs[1:]:
hline(c, cx-1.5, cx-1.5, sk_bot, A, lw=0.5)
# actually draw vertical line
c.saveState()
c.setStrokeColor(A)
c.setLineWidth(0.5)
c.setStrokeAlpha(0.3)
c.line(cx-1.5, sk_bot, cx-1.5, sk_bot+sk_h)
c.restoreState()
row_h = 12.5 # pts per skill row
cat_h = 13 # pts for category header
cat_gap = 4 # gap before category
char_skills = char['skills']
for col_idx, cats in enumerate(col_cats):
cx = col_xs[col_idx]
cw = col_w
cy = sk_bot + sk_h - 2 # start from top, going down
for cat_name, skill_list in cats:
cy -= cat_gap
# Category header bar
filled_rect(c, cx, cy - cat_h + 2, cw, cat_h, A)
text(c, cat_name, cx + 3, cy - cat_h + 5.5, 'OrbB', 6.5, white)
# Root max info on right of header
root_max_map = {
'AGILITY': f"max STR+DEX = {char['STR']}+{char['DEX']}",
'COMMUNICATION': f"max INT+APP = {char['INT']}+{char['APP']}",
'KNOWLEDGE': f"max INT+EDU = {char['INT']}+{char['EDU']}",
'PERCEPTION': f"max POW+CON = {char['POW']}+{char['CON']}",
'TECHNICAL': f"max DEX+INT = {char['DEX']}+{char['INT']}",
}
rm_text = root_max_map.get(cat_name, '')
text(c, rm_text, cx+cw-2, cy-cat_h+5.5, 'Mono', 4.8, white, align='right')
cy -= cat_h
for skill_name in skill_list:
val = get_skill(char_skills, skill_name)
base = BASE.get(skill_name, 0)
trained = val > base
cy -= row_h
# Alternate row shading
if skill_list.index(skill_name) % 2 == 1:
filled_rect(c, cx, cy-0.5, cw, row_h, HexColor('#f0f0f8'))
# Skill name
name_color = INK if trained else HexColor('#888899')
text(c, skill_name, cx+2, cy+2.5, 'Bar', 8, name_color)
# Value percentage
if trained:
pct_color = AD if val >= 70 else INK
font = 'OrbB'
else:
pct_color = HexColor('#aaaacc')
font = 'Orb'
text(c, f"{val}%", cx+cw-2, cy+2.5, font, 8, pct_color, align='right')
# Dot leaders between name and value
# Measure approximate text widths
sk_name_end = cx + 2 + len(skill_name) * 4.4 # approx
pct_start = cx + cw - 2 - len(f"{val}%") * 5 - 6
if pct_start > sk_name_end + 4:
hline(c, sk_name_end+3, pct_start, cy+3.5, HexColor('#ddddee'), lw=0.4)
# ── WEAPONS ───────────────────────────────────────────────────────────────
wpn_y = notes_h + foot_h
filled_rect(c, 0, wpn_y, PW, wpn_h, PALE)
hline(c, 0, PW, wpn_y + wpn_h, A, lw=1.5)
filled_rect(c, 0, wpn_y + wpn_h - 9, PW, 9, A)
text(c, 'WEAPONS & COMBAT', PAD, wpn_y + wpn_h - 6.5, 'OrbB', 8, white)
# Weapons — proper 4-column layout: NAME | SKILL% | DAMAGE | RANGE + notes
# Column x positions (relative to PAD)
uw = PW - 2*PAD
c_name = PAD # weapon name — 38% of uw
c_skill = PAD + uw*0.38 # skill% — 14%
c_dmg = PAD + uw*0.52 # damage — 25%
c_range = PAD + uw*0.77 # range — 23% (right-anchored)
# Column headers
hdr_y = wpn_y + wpn_h - 20
HDR_COL = HexColor('#555566')
text(c, 'WEAPON NAME', c_name+2, hdr_y, 'OrbB', 7, HDR_COL)
text(c, 'SKILL %', c_skill+2, hdr_y, 'OrbB', 7, HDR_COL)
text(c, 'DAMAGE', c_dmg+2, hdr_y, 'OrbB', 7, HDR_COL)
text(c, 'RANGE', c_range+2, hdr_y, 'OrbB', 7, HDR_COL)
hline(c, PAD, PW-PAD, hdr_y - 2, MID, lw=0.6)
# Thin column dividers in header
for cx in [c_skill, c_dmg, c_range]:
hline(c, cx, cx, hdr_y+7, HexColor('#ccccdd'), lw=0.3)
row_y = hdr_y - 3
for wi, wpn in enumerate(char['weapons']):
row_y -= 10*mm
wpn_bg = HexColor('#eeeefc') if wi % 2 == 0 else white
filled_rect(c, PAD, row_y - 1*mm, PW - 2*PAD, 10*mm, wpn_bg)
# Vertical column dividers
for cx in [c_skill, c_dmg, c_range]:
hline(c, cx-1, cx-1, row_y+9*mm, HexColor('#d8d8ec'), lw=0.4)
c.saveState(); c.setStrokeColor(HexColor('#d8d8ec')); c.setLineWidth(0.4)
c.line(cx-1, row_y-1*mm, cx-1, row_y+9*mm)
c.restoreState()
# Col 1: Weapon name (bold) + notes sub-line
if len(wpn) > 0:
text(c, str(wpn[0]), c_name+3, row_y+5.5*mm, 'Bar', 10, INK)
if len(wpn) > 4 and wpn[4]:
text(c, str(wpn[4]), c_name+3, row_y+1.8*mm, 'Mono', 6.5,
HexColor('#555566'))
# Col 2: Skill % — large accent
if len(wpn) > 1:
text(c, str(wpn[1]), c_skill + (uw*0.14/2), row_y+4.5*mm,
'OrbB', 11, AD, align='centre')
# Col 3: Damage — word-boundary wrapping, two lines max
if len(wpn) > 2:
dmg = str(wpn[2])
# Word-wrap at ~17 chars to stay within damage column
import textwrap as _tw
lines = _tw.wrap(dmg, width=17) or [dmg]
if len(lines) == 1:
text(c, lines[0], c_dmg+3, row_y+4.5*mm, 'Bar', 10, INK)
else:
text(c, lines[0], c_dmg+3, row_y+6.2*mm, 'Bar', 9, INK)
text(c, lines[1], c_dmg+3, row_y+2.2*mm, 'Bar', 8.5, INK)
# Col 4: Range
if len(wpn) > 3:
rng = str(wpn[3])
text(c, rng, c_range+3, row_y+4.5*mm, 'Mono', 8,
HexColor('#333355'))
hline(c, PAD, PW-PAD, row_y-1.5, HexColor('#ccccdd'), lw=0.3)
# ── EQUIPMENT + PERSONALITY ───────────────────────────────────────────────
notes_y = foot_h
half_w = (PW - 2*PAD - 3) / 2
# Equipment (left)
filled_rect(c, 0, notes_y, half_w + PAD + 1.5, notes_h, white)
outline_rect(c, 0, notes_y, half_w + PAD + 1.5, notes_h, A, lw=1.2)
text(c, 'EQUIPMENT', PAD, notes_y + notes_h - 7, 'OrbB', 7.5, A)
eq_lines = char['equip']
ey = notes_y + notes_h - 16
for eq in eq_lines:
text(c, '▸ ' + eq, PAD, ey, 'Bar', 8, INK)
ey -= 9
# Personality (right)
rx = half_w + PAD + 3
filled_rect(c, rx, notes_y, PW - rx, notes_h, white)
outline_rect(c, rx, notes_y, PW - rx, notes_h, A, lw=1.2)
text(c, 'CHARACTER', rx + 3, notes_y + notes_h - 7, 'OrbB', 7.5, A)
pers_lines = char['personality']
py = notes_y + notes_h - 16
for line in pers_lines:
text(c, line, rx + 3, py, 'Bar', 9, INK)
py -= 10
# ── FOOTER ────────────────────────────────────────────────────────────────
filled_rect(c, 0, 0, PW, foot_h, HexColor('#f0f0f8'))
hline(c, 0, PW, foot_h, A, lw=1)
text(c, f'No.{char["num"]} · {char["name"].upper()} · {char["role"]}',
PAD, 2.5*mm, 'Mono', 6, A)
text(c, 'RINGWORLD RPG 1984 · CHAOSIUM INC.',
PW-PAD, 2.5*mm, 'Mono', 6, HexColor('#444466'), align='right')
# ─── Back page ────────────────────────────────────────────────────────────────
def draw_puppeteer_hloc(c, char):
"""Pierson's Puppeteer hit location diagram — two heads, two forelegs, rear leg, body."""
A = char['acc']
AD = char['acc_dark']
hp = char['hp']
con = int(char['CON'])
hloc_y_top = PH - 10*mm
hloc_h = 148*mm
hloc_y_bot = hloc_y_top - hloc_h
filled_rect(c, 0, hloc_y_bot, PW, hloc_h, HexColor('#13132a'))
hline(c, 0, PW, hloc_y_bot, A, lw=1.5)
bar_h = 11
bar_y = hloc_y_top - bar_h - 3
filled_rect(c, 0, bar_y, PW, bar_h, A)
text(c, "HIT LOCATIONS & DAMAGE — PIERSON'S PUPPETEER", PAD, bar_y+3, 'OrbB', 8, INK)
body_hp = math.ceil(hp * 0.40)
neck_hp = math.ceil(hp * 0.20)
head_hp = math.ceil(hp * 0.25)
foreleg_hp = math.ceil(hp * 0.20)
rearleg_hp = math.ceil(hp * 0.25)
unc = math.ceil(hp * 0.25)
content_top = bar_y
dia_w = 70*mm
bx = PAD + dia_w / 2
body_cy = content_top - 65*mm
# Barrel body
c.saveState(); c.setStrokeColor(A); c.setLineWidth(2); c.setFillColor(Color(0,0,0,0))
c.ellipse(bx-20*mm, body_cy-16*mm, bx+20*mm, body_cy+14*mm, fill=0, stroke=1)
c.restoreState()
text(c, 'BODY', bx, body_cy+4*mm, 'OrbB', 10, A, align='centre')
text(c, f'{body_hp} HP', bx, body_cy-2*mm, 'OrbB', 11, white, align='centre')
text(c, '01-07', bx, body_cy-8*mm, 'Mono', 9, MID, align='centre')
# Forelegs
fl_top = body_cy - 16*mm
fl_bh = 32*mm; fl_bw = 12*mm
for side, lbl, rng in [(-1,'R FORE','08-10'),(+1,'L FORE','11-12')]:
fx = bx + side*28*mm
c.saveState(); c.setStrokeColor(A); c.setLineWidth(1.8); c.setFillColor(Color(0,0,0,0))
c.rect(fx-fl_bw/2, fl_top-fl_bh, fl_bw, fl_bh, fill=0, stroke=1)
c.restoreState()
text(c, lbl, fx, fl_top-13*mm, 'OrbB', 9, A, align='centre')
text(c, f'{foreleg_hp} HP',fx, fl_top-19*mm, 'OrbB', 10, white, align='centre')
text(c, rng, fx, fl_top-25*mm, 'Mono', 8, MID, align='centre')
# Rear leg
rl_bh = 32*mm; rl_bw = 12*mm
c.saveState(); c.setStrokeColor(A); c.setLineWidth(1.8); c.setFillColor(Color(0,0,0,0))
c.rect(bx-rl_bw/2, fl_top-rl_bh, rl_bw, rl_bh, fill=0, stroke=1)
c.restoreState()
text(c, 'REAR LEG', bx, fl_top-13*mm, 'OrbB', 9, A, align='centre')
text(c, f'{rearleg_hp} HP',bx, fl_top-19*mm, 'OrbB', 10, white, align='centre')
text(c, '13-14', bx, fl_top-25*mm, 'Mono', 8, MID, align='centre')
# Necks (curved bezier)
neck_base = body_cy + 14*mm
neck_tip_l = (bx-22*mm, neck_base+32*mm)
neck_tip_r = (bx+22*mm, neck_base+32*mm)
c.saveState(); c.setStrokeColor(A); c.setLineWidth(9); c.setStrokeAlpha(0.15)
c.bezier(bx-6*mm, neck_base, bx-14*mm, neck_base+16*mm,
neck_tip_l[0]+4*mm, neck_tip_l[1]-6*mm, neck_tip_l[0], neck_tip_l[1])
c.bezier(bx+6*mm, neck_base, bx+14*mm, neck_base+16*mm,
neck_tip_r[0]-4*mm, neck_tip_r[1]-6*mm, neck_tip_r[0], neck_tip_r[1])
c.restoreState()
c.saveState(); c.setStrokeColor(A); c.setLineWidth(1.5)
c.bezier(bx-6*mm, neck_base, bx-14*mm, neck_base+16*mm,
neck_tip_l[0]+4*mm, neck_tip_l[1]-6*mm, neck_tip_l[0], neck_tip_l[1])
c.bezier(bx+6*mm, neck_base, bx+14*mm, neck_base+16*mm,
neck_tip_r[0]-4*mm, neck_tip_r[1]-6*mm, neck_tip_r[0], neck_tip_r[1])
c.restoreState()
# neck labels at midpoint
for side, lbl, rng, tx, ty in [
(-1,'L NECK','15-16', bx-26*mm, neck_base+15*mm),
(+1,'R NECK','17-18', bx+26*mm, neck_base+15*mm),
]:
text(c, lbl, tx, ty, 'OrbB', 7.5, A, align='centre')
text(c, f'{neck_hp}HP', tx, ty-5*mm, 'Mono', 7, white, align='centre')
text(c, rng, tx, ty-9*mm, 'Mono', 7, MID, align='centre')
# Heads
for hx, hy, lbl, rng in [
(neck_tip_l[0], neck_tip_l[1], 'L HEAD', '20'),
(neck_tip_r[0], neck_tip_r[1], 'R HEAD', '19'),
]:
c.saveState(); c.setStrokeColor(A); c.setLineWidth(2); c.setFillColor(Color(0,0,0,0))
c.ellipse(hx-9*mm, hy, hx+9*mm, hy+14*mm, fill=0, stroke=1)
c.restoreState()
text(c, lbl, hx, hy+9*mm, 'OrbB', 9, A, align='centre')
text(c, f'{head_hp} HP',hx, hy+4*mm, 'OrbB', 10, white, align='centre')
text(c, rng, hx, hy+0.5*mm,'Mono', 8, MID, align='centre')
# Location table (right side) — reuse same column structure
tbl_x = PAD + dia_w + 5*mm
tbl_w = PW - tbl_x - PAD
tbl_y = content_top - 20
c1=tbl_x; c2=tbl_x+28*mm; c3=tbl_x+46*mm; c4=tbl_x+64*mm; c5=tbl_x+82*mm; c6=tbl_x+100*mm
for lbl,cx in [('LOCATION',c1),('MELEE',c2),('RANGED',c3),('MAX HP',c4),('CURR HP',c5),('ARMOUR',c6)]:
text(c, lbl, cx, tbl_y, 'OrbB', 7.5, A)
hline(c, tbl_x, PW-PAD, tbl_y-3, A, lw=1.2)
pup_locs = [
('Body', '01-07','01-07', body_hp),
('Right Fore', '08-10','08-10', foreleg_hp),
('Left Fore', '11-12','11-12', foreleg_hp),
('Rear Leg', '13-14','13-14', rearleg_hp),
('Right Neck', '15-16','15-16', neck_hp),
('Left Neck', '17-18','17-18', neck_hp),
('Right Head', '19', '19', head_hp),
('Left Head', '20', '20', head_hp),
]
row_h = 12*mm; ry = tbl_y - 3
for i,(loc,m_rng,r_rng,loc_hp) in enumerate(pup_locs):
ry -= row_h
bg = HexColor('#1a1a3a') if i%2==0 else HexColor('#121228')
filled_rect(c, tbl_x-1, ry, tbl_w+1, row_h-1, bg)
mid_y = ry + row_h*0.42
text(c, loc, c1+2, mid_y, 'Bar', 11, white)
text(c, m_rng, c2+9*mm, mid_y, 'Mono', 9.5, MID, align='centre')
text(c, r_rng, c3+9*mm, mid_y, 'Mono', 9.5, MID, align='centre')
text(c, str(loc_hp), c4+8*mm, mid_y, 'OrbB', 14, A, align='centre')
bh = row_h-4*mm
filled_rect(c, c5, ry+2*mm, 16*mm, bh, white)
outline_rect(c, c5, ry+2*mm, 16*mm, bh, A, lw=1.2)
filled_rect(c, c6, ry+2*mm, 16*mm, bh, white)
outline_rect(c, c6, ry+2*mm, 16*mm, bh, A, lw=1.2)
hline(c, tbl_x-1, PW-PAD, ry, HexColor('#181830'), lw=0.5)
unc_y = ry - 5*mm
filled_rect(c, tbl_x-1, unc_y, tbl_w+1, 14, HexColor('#1a0808'))
outline_rect(c, tbl_x-1, unc_y, tbl_w+1, 14, A, lw=1.3)
text(c, f'UNCONSCIOUS: HP ≤ {unc} · DYING: HP = 0 · DEAD: HP = −{con}',
tbl_x+4, unc_y+4.5, 'Mono', 7.5, A)
tally_y = hloc_y_bot + 12*mm # anchored from section bottom
text(c, 'HP TALLY', tbl_x, tally_y+9, 'OrbB', 7.5, A)
cr = 5; cstep = 14; row_start_x = tbl_x + 26*mm
for i in range(20):
cx_c = row_start_x + i*cstep + cr
cy_c = tally_y + cr + 1
c.saveState()
c.setStrokeColor(A); c.setLineWidth(1.2)
c.setFillColor(white)
c.circle(cx_c, cy_c, cr, fill=1, stroke=1)
c.setFont('Mono',5); c.setFillColor(HexColor('#8899aa'))
c.drawCentredString(cx_c, cy_c-cr-3.5, str(i+1))
c.restoreState()
def draw_back(c, char, page_num, total_pages):
A = char['acc']
AD = char['acc_dark']
hp = char['hp']
con = int(char['CON'])
# ── BACK MASTHEAD ─────────────────────────────────────────────────────────
mast_h = 10*mm
filled_rect(c, 0, PH-mast_h, PW, mast_h, INK)
filled_rect(c, 0, PH-1.5*mm, PW, 1.5*mm, A)
text(c, char['name'].upper(), PAD, PH-7*mm, 'OrbB', 11, A)
text(c, char['role'].upper() + ' · REVERSE', PW-PAD, PH-7*mm, 'Mono', 6.5, MID, align='right')
# ── HIT LOCATION SECTION ──────────────────────────────────────────────────
hloc_y_top = PH - mast_h
hloc_h = 148*mm
hloc_y_bot = hloc_y_top - hloc_h
if char.get('species','').startswith("Pierson"):
draw_puppeteer_hloc(c, char)
else:
filled_rect(c, 0, hloc_y_bot, PW, hloc_h, HexColor('#13132a'))
hline(c, 0, PW, hloc_y_bot, A, lw=1.5)
# Section header — 3mm gap below masthead bottom, text clear of bar
bar_h = 11
bar_y = hloc_y_top - bar_h - 3
filled_rect(c, 0, bar_y, PW, bar_h, A)
text(c, 'HIT LOCATIONS & DAMAGE', PAD, bar_y + 3, 'OrbB', 8, INK)
arm_hp = math.ceil(hp * 0.25)
leg_hp = math.ceil(hp * 0.30)
head_hp = math.ceil(hp * 0.30)
abd_hp = math.ceil(hp * 0.30)
chest_hp = math.ceil(hp * 0.35)
unc = math.ceil(hp * 0.25)
content_top = bar_y # drawing starts below the header bar
# ── Body diagram (left, 70mm wide) ────────────────────────────────
dia_w = 70*mm
bx = PAD + dia_w / 2
# HEAD
by_head_c = content_top - 18*mm
c.saveState()
c.setStrokeColor(A); c.setLineWidth(2); c.setFillColor(Color(0,0,0,0))
c.ellipse(bx-12*mm, by_head_c-9*mm, bx+12*mm, by_head_c+11*mm, fill=0, stroke=1)
c.restoreState()
text(c, 'HEAD', bx, by_head_c+2*mm, 'OrbB', 10, A, align='centre')
text(c, f'{head_hp} HP',bx, by_head_c-3.5*mm,'OrbB', 11, white, align='centre')
text(c, '19-20', bx, by_head_c-8.5*mm,'Mono', 9, MID, align='centre')
# Neck
neck_top = by_head_c - 9*mm
c.saveState(); c.setStrokeColor(A); c.setLineWidth(1.3); c.setFillColor(Color(0,0,0,0))
c.rect(bx-4.5*mm, neck_top-7*mm, 9*mm, 7*mm, fill=0, stroke=1)
c.restoreState()
# CHEST
chest_top = neck_top - 7*mm
chest_w = 36*mm
chest_bh = 30*mm
c.saveState(); c.setStrokeColor(A); c.setLineWidth(2); c.setFillColor(Color(0,0,0,0))
c.rect(bx-chest_w/2, chest_top-chest_bh, chest_w, chest_bh, fill=0, stroke=1)
c.restoreState()
text(c, 'CHEST', bx, chest_top-11*mm, 'OrbB', 10, A, align='centre')
text(c, f'{chest_hp} HP',bx, chest_top-17*mm, 'OrbB', 11, white, align='centre')
text(c, '11-15', bx, chest_top-23*mm, 'Mono', 9, MID, align='centre')
# ABDOMEN
abd_top = chest_top - chest_bh
abd_bh = 20*mm
abd_w = 28*mm
c.saveState(); c.setStrokeColor(A); c.setLineWidth(1.8); c.setFillColor(Color(0,0,0,0))
c.rect(bx-abd_w/2, abd_top-abd_bh, abd_w, abd_bh, fill=0, stroke=1)
c.restoreState()
text(c, 'ABDOMEN', bx, abd_top-7*mm, 'OrbB', 10, A, align='centre')
text(c, f'{abd_hp} HP', bx, abd_top-13*mm, 'OrbB', 11, white, align='centre')
text(c, '07-10', bx, abd_top-18*mm, 'Mono', 9, MID, align='centre')
# ARMS
arm_bh = 36*mm
arm_bw = 13*mm
gap = 3*mm
for side, lbl, rng in [(-1,'R ARM','16-17'),(+1,'L ARM','18-19')]:
ax = bx + side*(chest_w/2 + gap + arm_bw/2)
c.saveState(); c.setStrokeColor(A); c.setLineWidth(1.8); c.setFillColor(Color(0,0,0,0))
c.rect(ax-arm_bw/2, chest_top-arm_bh, arm_bw, arm_bh, fill=0, stroke=1)
c.restoreState()
text(c, lbl, ax, chest_top-14*mm, 'OrbB', 9, A, align='centre')
text(c, f'{arm_hp} HP',ax, chest_top-20*mm, 'OrbB', 10, white, align='centre')
text(c, rng, ax, chest_top-26*mm, 'Mono', 8, MID, align='centre')
# LEGS
leg_top = abd_top - abd_bh
leg_bh = 38*mm
leg_bw = 14*mm
sep = 3*mm
for side, lbl, rng in [(-1,'R LEG','01-03'),(+1,'L LEG','04-06')]:
lx = bx + side*(leg_bw/2 + sep/2)
c.saveState(); c.setStrokeColor(A); c.setLineWidth(1.8); c.setFillColor(Color(0,0,0,0))
c.rect(lx-leg_bw/2, leg_top-leg_bh, leg_bw, leg_bh, fill=0, stroke=1)
c.restoreState()
text(c, lbl, lx, leg_top-15*mm, 'OrbB', 9, A, align='centre')
text(c, f'{leg_hp} HP',lx, leg_top-21*mm, 'OrbB', 10, white, align='centre')
text(c, rng, lx, leg_top-27*mm, 'Mono', 8, MID, align='centre')
# ── Location table (right side) ───────────────────────────────────
tbl_x = PAD + dia_w + 5*mm
tbl_w = PW - tbl_x - PAD
tbl_y = content_top - 20
# Col positions
c1 = tbl_x
c2 = tbl_x + 28*mm
c3 = tbl_x + 46*mm
c4 = tbl_x + 64*mm
c5 = tbl_x + 82*mm
c6 = tbl_x + 100*mm
# Column headers
for lbl, cx in [('LOCATION',c1),('MELEE',c2),('RANGED',c3),
('MAX HP',c4),('CURR HP',c5),('ARMOUR',c6)]:
text(c, lbl, cx, tbl_y, 'OrbB', 7.5, A)
hline(c, tbl_x, PW-PAD, tbl_y - 3, A, lw=1.2)
locs = [
('Head', '19-20','20', head_hp),
('Right Arm','13-15','16-17', arm_hp),
('Left Arm', '16-18','18-19', arm_hp),
('Chest', '12', '11-15', chest_hp),
('Abdomen', '09-11','07-10', abd_hp),
('Right Leg','01-04','01-03', leg_hp),
('Left Leg', '05-08','04-06', leg_hp),
]
row_h = 14*mm
ry = tbl_y - 3
for i,(loc,m_rng,r_rng,loc_hp) in enumerate(locs):
ry -= row_h
bg = HexColor('#1a1a3a') if i%2==0 else HexColor('#121228')
filled_rect(c, tbl_x-1, ry, tbl_w+1, row_h-1, bg)
mid_y = ry + row_h * 0.42
text(c, loc, c1+2, mid_y, 'Bar', 11, white)
text(c, m_rng, c2+9*mm, mid_y, 'Mono', 9.5, MID, align='centre')
text(c, r_rng, c3+9*mm, mid_y, 'Mono', 9.5, MID, align='centre')
text(c, str(loc_hp), c4+8*mm, mid_y, 'OrbB', 14, A, align='centre')
bh = row_h - 4*mm
# CURR HP — blank write-in box
filled_rect(c, c5, ry+2*mm, 16*mm, bh, white)
outline_rect(c, c5, ry+2*mm, 16*mm, bh, A, lw=1.3)
# ARMOUR — populate from char data if present
arm_val = char.get('armour', {}).get(loc, '')
arm_bg = HexColor('#e8f4e8') if arm_val and arm_val != '—' else white
filled_rect(c, c6, ry+2*mm, 16*mm, bh, arm_bg)
outline_rect(c, c6, ry+2*mm, 16*mm, bh, A, lw=1.3)
if arm_val:
text(c, arm_val, c6+8*mm, ry+2*mm+bh*0.38, 'OrbB', 7, A, align='centre')
hline(c, tbl_x-1, PW-PAD, ry, HexColor('#181830'), lw=0.5)
# Unconscious / dying banner — clear gap above
unc_y = ry - 5*mm
filled_rect(c, tbl_x-1, unc_y, tbl_w+1, 14, HexColor('#1a0808'))
outline_rect(c, tbl_x-1, unc_y, tbl_w+1, 14, A, lw=1.3)
text(c, f'UNCONSCIOUS: HP ≤ {unc} · DYING: HP = 0 · DEAD: HP = −{con}',
tbl_x+4, unc_y+4.5, 'Mono', 7.5, A)
# HP tally — 20 small white circles to cross out
tally_y = hloc_y_bot + 12*mm # anchored from section bottom
text(c, 'HP TALLY', tbl_x, tally_y+9, 'OrbB', 7.5, A)
cr = 5; cstep = 14; row_start_x = tbl_x + 26*mm
for i in range(20):
cx_c = row_start_x + i*cstep + cr
cy_c = tally_y + cr + 1
c.saveState()
c.setStrokeColor(A); c.setLineWidth(1.2)
c.setFillColor(white)
c.circle(cx_c, cy_c, cr, fill=1, stroke=1)
c.setFont('Mono', 5); c.setFillColor(HexColor('#8899aa'))
c.drawCentredString(cx_c, cy_c-cr-3.5, str(i+1))
c.restoreState()
# Under-diagram label (bottom of diagram area)
unc_lbl_y = hloc_y_bot + 2*mm
filled_rect(c, PAD, unc_lbl_y, dia_w - 2*mm, 11, HexColor('#100608'))
outline_rect(c, PAD, unc_lbl_y, dia_w - 2*mm, 11, A, lw=0.9)
text(c, f'UNCONSIOUS ≤ {unc} HP', PAD + (dia_w-2*mm)/2,
unc_lbl_y+3, 'OrbB', 7.5, A, align='centre')
# ── COMBAT QUICK REFERENCE ────────────────────────────────────────────────
cref_h = 34*mm
cref_y = hloc_y_bot - cref_h
filled_rect(c, 0, cref_y, PW, cref_h, HexColor('#0c0c1a'))
hline(c, 0, PW, cref_y + cref_h, A, lw=1.5)
section_label(c, 0, cref_y + cref_h - 9, PW, 'COMBAT QUICK REFERENCE', A, size=7)
col1_x, col2_x, col3_x = PAD, PW*0.33, PW*0.66
# Action ranking
text(c, 'ACTION RANKING', col1_x, cref_y+cref_h-18, 'OrbB', 6, A)
ar_data = [('DEX 1-4','AR 7'),('DEX 5-8','AR 6'),('DEX 9-12','AR 5'),
('DEX 13-16','AR 4'),('DEX 17-20','AR 3'),('DEX 21+','AR 2-1')]
dex = int(char['DEX'])
ay = cref_y + cref_h - 27
for dex_rng, ar_val in ar_data:
rng_parts = dex_rng.replace('DEX ','').split('-') if '-' in dex_rng else [dex_rng.replace('DEX ',''),dex_rng.replace('DEX ','')]
lo = int(rng_parts[0].replace('+',''))
hi = int(rng_parts[-1].replace('+','999'))
active = lo <= dex <= hi
row_col = A if active else HexColor('#333355')
row_bg = HexColor('#1a1a30') if active else Color(0,0,0,0)
if active:
filled_rect(c, col1_x-1, ay-2, 45*mm, 9, HexColor('#1a1a30'))
text(c, dex_rng, col1_x, ay, 'Mono', 6.5, A if active else HexColor('#666688'))
text(c, f'◈ {ar_val}' if active else ar_val, col1_x+25*mm, ay, 'OrbB', 6.5 if active else 6, A if active else HexColor('#555577'))
ay -= 8
# Range modifiers
text(c, 'RANGE MODIFIERS', col2_x, cref_y+cref_h-18, 'OrbB', 6, A)
range_data = [('Point-blank (≤DEX m)','×1.5 (still=auto hit)'),
('Short range','Normal skill %'),
('Medium range','½ skill %'),
('Long range','¼ skill %'),
('No aim (snap)','¼ skill %'),
('Half action rank aim','½ skill %')]
ry2 = cref_y+cref_h-27
for rng, mod in range_data:
text(c, rng, col2_x, ry2, 'Bar', 7, HexColor('#aaaacc'))
text(c, mod, col2_x + 33*mm, ry2, 'Mono', 6, HexColor('#ccccee'))
ry2 -= 8
# Combat rules
text(c, 'COMBAT RULES', col3_x, cref_y+cref_h-18, 'OrbB', 6, A)
rules = [
'SPECIAL SUCCESS: roll ≤ skill÷5',
'DODGE: DEX×3%, costs 1 impulse',
'PARRY: roll weapon skill, 1 impulse',
'SURPRISE: lose action rank impulses',
'MELEE: full action rank to attack',
'MOVE: 3m/impulse (Kzin: 5m/10m)',
]
ry3 = cref_y+cref_h-27
for rule in rules:
text(c, rule, col3_x, ry3, 'Mono', 6, HexColor('#aaaacc'))
ry3 -= 8
# ── SCENARIO HOOKS ────────────────────────────────────────────────────────
hook_h = 40*mm
hook_y = cref_y - hook_h
hline(c, 0, PW, cref_y, A, lw=1.5)
half = (PW - 2*PAD - 3) / 2
# Event 95 (left)
filled_rect(c, 0, hook_y, PW/2, hook_h, white)
filled_rect(c, 0, hook_y + hook_h - 9, PW/2, 9, A)
text(c, 'EVENT 95 — A QUESTION OF SINGULARITY', PAD, hook_y+hook_h-6, 'OrbB', 6.5, white)
h95_lines = char['hook95']
hy = hook_y + hook_h - 17
for line in h95_lines:
text(c, line, PAD, hy, 'Bar', 7.5, HexColor('#222233'))
hy -= 8.5
# Event 96 (right)
filled_rect(c, PW/2+1.5, hook_y, PW/2-1.5, hook_h, white)
hline(c, PW/2, PW/2, hook_y, A, lw=1)
c.saveState(); c.setStrokeColor(A); c.setLineWidth(1)
c.line(PW/2, hook_y, PW/2, hook_y+hook_h); c.restoreState()
filled_rect(c, PW/2+1.5, hook_y + hook_h - 9, PW/2-1.5, 9, A)
text(c, 'EVENT 96 — IS LOVE THE ANSWER?', PW/2+PAD, hook_y+hook_h-6, 'OrbB', 6.5, white)
hy = hook_y + hook_h - 17
for line in char['hook96']:
text(c, line, PW/2+PAD, hy, 'Bar', 7.5, HexColor('#222233'))
hy -= 8.5
# ── SESSION NOTES (full-width — equipment is on front page) ──────────────
eq_h = hook_y
eq_y = 8*mm
eq_sec_h = eq_h - eq_y
filled_rect(c, 0, eq_y, PW, eq_sec_h, white)
hline(c, 0, PW, hook_y, A, lw=1.5)
filled_rect(c, 0, eq_y+eq_sec_h-9, PW, 9, A)
text(c, 'SESSION NOTES', PAD, eq_y+eq_sec_h-6, 'OrbB', 6.5, INK)
ny = eq_y + eq_sec_h - 17
while ny > eq_y + 6:
hline(c, PAD, PW-PAD, ny, HexColor('#ccccdd'), lw=0.4)
ny -= 10
# ── BACK FOOTER ───────────────────────────────────────────────────────────
filled_rect(c, 0, 0, PW, 8*mm, INK)
hline(c, 0, PW, 8*mm, A, lw=1)
text(c, f'No.{char["num"]} / RINGWORLD RPG 1984 / CHAOSIUMCON UK 2026',
PAD, 2.5*mm, 'Mono', 6, A)
text(c, f'{page_num}/{total_pages}',
PW-PAD, 2.5*mm, 'Mono', 6, MID, align='right')
# ══════════════════════════════════════════════════════════════════════════════
# CHARACTER DATA
# ══════════════════════════════════════════════════════════════════════════════
from reportlab.lib.colors import HexColor as H2 # local alias
CHARACTERS = [
{
'num':1,'name':'Dr. Mira Chen',
'role':'Xenobiologist · Lead Scientist',
'species':'Human · Earth','context':'Survey team, Foolish Endeavour',
'acc':HexColor('#00c8ff'),'acc_dark':HexColor('#00507a'),
'STR':'09','CON':'11','SIZ':'10','INT':'18','POW':'13','DEX':'11','APP':'13','EDU':'18',
'hp':11,'move':'3m/impulse','db':'None','pp':'13',
'portrait_path':'/home/claude/ChaosiumCon26/scenarios/art/event-96/portrait-mira-chen.jpg',
'skills':{
'Athletics':20,'Hide':10,'Sneak':5,'Unarmed Combat':10,
'Bargain':25,'Debate':30,'Fast Talk':18,'Orate':15,
'Own Language':90,'Psychology':42,'Persuade':55,
'Anthropology':48,'Astronomy':22,'Biology':72,'Botany':35,
'Chemistry':40,'Computers':50,'Emergency Treatment':45,
'Engineering':10,'History':30,'Linguistics':65,'Medicine':70,
'Physics':18,'Research':75,'Science (Xenobiology)':85,
'Strategy':15,'Zoology':55,'Second Language':20,
'Awareness':60,'Observe':52,'Search':35,'Listen':32,
'Personal Flyer':15,'Repair':10,'Ringworld':5,
},
'weapons':[
('Sonic Stunner','30%','Stun (non-lethal)','5 / 20 / 50m','Cone effect; non-lethal'),
('Field Syringe','20%','Sedative / Special','Touch only','Medical tool'),
],
'equip':[
'Medical kit','Portable bio-scanner','Recording equipment',
'Personal data tablet','Field sample containers','Comdisc (ship link)',
'Emergency beacon',
],
'equip_detail':[
'Medical kit (full surgical)','Bio-scanner (portable)','Data tablet',
'Recording equipment','Field sample containers × 12',
'Comdisc (ship link)','Emergency beacon','Survey pack',
],
'personality':[
'"The absence of biology IS the data."',
'Precise, methodical, narrates aloud.',
'Will care about Serenthis before',
'she realises she is doing it.',
],
'armour': {}, # No armour
'hook95':['Your bio-scanner shows the zone\'s ecological void as sharply as a',
'wound. Linguistics translates system logs. Medicine reads Marr\'s',
'condition. In Act Three the system displays your xenobiological',
'profile — it catalogued you as a variable the moment you arrived.',
'▸ KEY: Science (Xenobiology) · Linguistics · Medicine'],
'hook96':['Serenthis calls you by name before you introduce yourself. Your',
'training recognises neurological encoding, not just language. Something',
'has been written into her. The bond runs through you.',
'▸ KEY: Linguistics · Awareness · Persuade in Act 3'],
},
{
'num':2,'name':'Lt. Yashti Korr',
'role':'Military Liaison · Security Lead',
'species':'Human · Wunderland','context':'Former ARM officer, survey secondment',
'acc':HexColor('#ff6600'),'acc_dark':HexColor('#884400'),
'STR':'14','CON':'15','SIZ':'13','INT':'14','POW':'12','DEX':'15','APP':'12','EDU':'14',
'hp':14,'move':'3m/impulse','db':'+1D4','pp':'12',
'portrait_path':'/home/claude/ChaosiumCon26/scenarios/art/event-96/portrait-yashti-korr.jpg',
'skills':{
'Athletics':45,'Sneak':50,'Hide':45,'Unarmed Combat':62,
'V.Sword / F.Laser':75,
'Bargain':28,'Debate':22,'Fast Talk':38,'Own Language':85,'Psychology':42,'Persuade':45,
'Computers':40,'Emergency Treatment':52,'History':32,'Law':45,'Strategy':55,
'Handgun Energy':75,'Handgun Projectile':68,'Heavy Weapon Energy':50,
'Heavy Weapon Proj.':42,'Listen':55,'Observe':65,'Search':60,'Track':48,
'Ground Vehicle':50,'Atmospheric Craft':38,'Personal Flyer':35,'Repair':22,
'Weapons System':40,'Ringworld':5,
},
'weapons':[
('Flashlight-Laser','75%','2D6+2','25 / 100 / 500m','3 power settings'),
('ARM Needle Gun','65%','1D3 + paralytic','10 / 40 / 100m','Silent; preferred'),
('Combat Knife','65%','1D4+1D4','Touch','Last option'),
],
'equip':['Flashlight-laser','ARM needle gun','Combat knife',
'Body armour AP2 (torso)','Comms unit','Restraints','Basic medkit'],
'equip_detail':['Flashlight-laser (3 settings)','ARM needle gun + 12 darts',
'Combat knife','Body armour AP2 (torso only)','Comms unit',
'Restraints × 4','Basic field medkit','Emergency beacon'],
'personality':[
'"Dangerous moment isn\'t when things',
'go wrong. It\'s when everyone decides',
'they\'re fine." The needle gun is',
'preferred. Wunderland aristocracy.',
],
'armour': {'Chest':'AP 2','Abdomen':'AP 2'}, # Body armour AP2 torso
'hook95':['Awareness makes you first to notice repetition. You\'ll set perimeters',
'when no one else thinks to. In Act Three, when Vorn orders withdrawal,',
'the crew looks to you: follow orders, or stay for the science?',
'▸ KEY: Awareness · Tactics · Combat if loops turn physical'],
'hook96':['You read Ghresh-Ka before the crew does — converging, not hostile yet.',
'If RR\'raan challenges him directly, back them. It\'s the only gambit.',
'Your job if it goes to a fight is to buy time, not to win.',
'▸ KEY: Tactics · Awareness · First Aid if crew hit'],
},
{
'num':3,'name':"RR'raan",
'role':'Combat Specialist · Kzin Noble',
'species':'Kzin · Patriarchy','context':'Eighth-litter, minor noble house',
'acc':HexColor('#ff2222'),'acc_dark':HexColor('#880000'),
'STR':'22','CON':'16','SIZ':'20','INT':'13','POW':'11','DEX':'16','APP':'08','EDU':'11',
'hp':18,'move':'5m / 10m sprint','db':'+2D6','pp':'11',
'portrait_path':'/home/claude/ChaosiumCon26/scenarios/art/event-96/portrait-rraan.jpg',
'skills':{
'Athletics':72,'Sneak':50,'Hide':45,'Unarmed Combat':78,
'V.Sword / F.Laser':70,'Archaic Melee Weapon':35,
'Own Language':90,'Psychology':38,'Bargain':22,'Persuade':30,
'History':38,'Strategy':50,'Zoology':40,'Planetology':25,'Emergency Treatment':25,
'Handgun Energy':65,'Heavy Weapon Energy':55,'Listen':70,
'Observe':65,'Search':60,'Track':75,'Scent':68,
'Personal Flyer':35,'Atmospheric Craft':30,'Ground Vehicle':28,
'Repair':20,'Weapons System':40,'Ringworld':8,
},
'weapons':[
('Kzin Claws × 2','80%','STR+1D6+2D6','Touch','Both hit = hold; 1D8+2/rnd auto'),
('Variable Sword','70%','1D6+4+2D6','Touch–5m','Molecular blade; cuts scrith'),
('Kzin Beam Pistol','65%','1D6+8','20 / 80 / 300m','Kzin military sidearm'),
],
'equip':['Kzin fighting claws (natural)','Variable sword','Kzin beam pistol',
'Personal comm unit','Kzin ration pack','Grapple line'],
'equip_detail':['Kzin fighting claws (natural weapon)','Variable sword',
'Kzin beam pistol + 2 power cells','Personal comm unit',
'Kzin ration pack (4 days)','Grapple line (20m)',
'[Kzin laser carbine stored shipboard]'],
'personality':[
'"This is science." Keeps saying it',
'because the humans need him to.',
'Patience is real. Frustration also.',
'Kzin honour = a weapon humans can\'t use.',
],
'armour': {'Head':'AP 1','Chest':'AP 1','Abdomen':'AP 1','Right Arm':'AP 1','Left Arm':'AP 1','Right Leg':'AP 1','Left Leg':'AP 1'}, # Natural Kzin hide AP 1
'hook95':['The quiet zone registers as total absence of biological signal — your',
'prey-senses detect it before instruments. Track + Awareness in Act 1.',
'In Act Three: a threat you cannot fight. The frustration is yours.',
'▸ KEY: Scent + Awareness · Tracking · CON×5 for descent'],
'hook96':['Ghresh-Ka is Kzin. Patient — you read it as contempt. Act Three:',
'challenge him one-on-one, Kzin honour code. Pauses his force.',
'Buys the crew time. May get you killed. May not. Your choice.',
'▸ KEY: Intimidate (the challenge) · Combat if honour demands'],
},
{
'num':4,'name':'Whisper-of-Distant-Worlds',
'role':'Intelligence Analyst · Puppeteer',
'species':"Pierson's Puppeteer",'context':'Two heads · Three legs · Ship analyst',
'acc':HexColor('#aa00ff'),'acc_dark':HexColor('#5c0099'),
'STR':'07','CON':'12','SIZ':'12','INT':'19','POW':'15','DEX':'14','APP':'10','EDU':'19',
'hp':12,'move':'3m/impulse','db':'None','pp':'15',
'portrait_path':'/home/claude/ChaosiumCon26/scenarios/art/event-96/portrait-whisper.jpg',
'skills':{
'Athletics':22,'Sneak':30,'Hide':25,'Unarmed Combat':5,
'Bargain':78,'Debate':65,'Fast Talk':80,'Own Language':95,'Orate':55,
'Persuade':85,'Psychology':82,
'Anthropology':70,'Astronomy':45,'Biology':40,'Chemistry':30,
'Computers':80,'Engineering':20,'History':55,'Law':60,
'Mathematics':65,'Physics':60,'Planetology':30,'Research':75,
'Strategy':70,'Zoology':45,'Second Language':65,
'Handgun Energy':10,'Listen':60,'Observe':72,'Search':58,
'Atmospheric Craft':25,'Hyperdrive':40,'Personal Flyer':35,
'Reactionless Drive':35,'Repair':15,'Ringworld':12,
},
'weapons':[
('Restraint Field','45%','Immobilize','5 / 15m cone','Puppeteer non-lethal'),
('Rear Hoof Kick','35%','1D6+2','Touch','Emergency; never admits'),
('Stepping Disc','95%','Escape','Instant','Teleport; 1/scene'),
],
'equip':['Personal data systems','Multi-freq comms array',
'Stepping disc harness','Restraint field generator','Analysis kit'],
'equip_detail':['Personal data systems (full)','Multi-freq comms array',
'Stepping disc harness (escape)','Restraint field generator',
'Analysis kit','Stun capsules × 3','[No conventional weapons]'],
'personality':[
'"I\'ve already accounted for that."',
'Will not fight. Will talk, flatter,',
'manipulate. Timing is everything.',
'Knows more than it shares. Always.',
],
'armour': {}, # No armour (Puppeteer)
'hook95':['You identify the constructing singularity before anyone else does.',
'Computer + Science (Physics) cracks the system logs in Act Two.',
'In Act Three you understand the system\'s logic better than anyone.',
'▸ KEY: Computer · Science (Physics) · Tactics for Act 3'],
'hook96':['You recognise what Serenthis is — Protector-class encoded Rememberer.',
'You will not share this immediately. Time it for maximum effect.',
'Persuade and Fast Talk are the crew\'s tool against Ghresh-Ka.',
'▸ KEY: Persuade / Fast Talk · Research · Awareness: Serenthis'],
},
{
'num':5,'name':'Tomas Veld',
'role':'Chief Engineer · Secondary Pilot',
'species':'Human · Jinx','context':'Heavy-gravity build; ship engineer',
'acc':HexColor('#ffbb00'),'acc_dark':HexColor('#886600'),
'STR':'13','CON':'14','SIZ':'12','INT':'15','POW':'12','DEX':'14','APP':'11','EDU':'16',
'hp':13,'move':'3m/impulse','db':'+1D4','pp':'12',
'portrait_path':'/home/claude/ChaosiumCon26/scenarios/art/event-96/portrait-tomas-veld.jpg',
'skills':{
'Athletics':35,'Sneak':15,'Unarmed Combat':25,
'Own Language':75,'Bargain':35,'Fast Talk':22,'Psychology':28,
'Chemistry':48,'Computers':60,'Emergency Treatment':45,
'Engineering':80,'History':30,'Mathematics':65,'Physics':55,
'Planetology':35,'Repair':75,'Strategy':38,
'Handgun Energy':45,'Observe':45,'Search':40,'Listen':35,
'Aquatic Vehicle':15,'Atmospheric Craft':55,'Ground Vehicle':55,
'Hyperdrive':40,'Personal Flyer':25,'Reaction Drive':48,
'Reactionless Drive':72,'Ringworld':8,'Weapons System':45,
},
'weapons':[
('Flashlight-Laser','45%','2D6+2','25 / 100 / 500m','Cutting/defence'),
('Gravity Planer','50%','3D6','5 / 20m cone','Tool; area effect'),
],
'equip':["Engineer's toolkit",'Portable power cell','Flashlight-laser',
'Gravity planer','Diagnostic scanner','Hard environment suit'],
'equip_detail':["Engineer's toolkit (full)",'Portable power cell (2)',
'Flashlight-laser','Gravity planer (engineering tool)',
'Diagnostic scanner','Repair materials × 3',
'Hard environment suit','Comdisc'],
'personality':[
'"It\'s a power draw I don\'t recognise.',
'That\'s a problem." Doesn\'t argue',
'about what can\'t change. Does argue',
'about what can. Loves the Ringworld.',
],
'armour': {'Head':'AP 1','Chest':'AP 1','Abdomen':'AP 1','Right Arm':'AP 1','Left Arm':'AP 1','Right Leg':'AP 1','Left Leg':'AP 1'}, # Hard environment suit
'hook95':['Your Engineering immediately flags the Spire drawing power far beyond',
'stated function. Science (Physics) + Computer crack the logs together.',
'In Act Three, you can feed the system the structural data it needs.',
'▸ KEY: Engineering · Science (Physics) + Computer · Pilot for exit'],
'hook96':['The vault power draw is active. Engineering tells you it is running.',
'Vorn\'s weather warning is your cue to calculate extraction windows.',
'You\'ll be flying the landing craft home through whatever the storm is.',
'▸ KEY: Engineering · Pilot for Act 3 extraction · Repair if needed'],
},
{
'num':6,'name':'Sola Reyes',
'role':'Survey Specialist · Navigator',
'species':'Human · We Made It','context':'17 Ringworld survey missions',
'acc':HexColor('#00cc55'),'acc_dark':HexColor('#006622'),
'STR':'11','CON':'12','SIZ':'11','INT':'16','POW':'13','DEX':'13','APP':'14','EDU':'17',
'hp':12,'move':'3m/impulse','db':'None','pp':'13',
'portrait_path':'/home/claude/ChaosiumCon26/scenarios/art/event-96/portrait-sola-reyes.jpg',
'skills':{
'Athletics':50,'Sneak':30,'Hide':28,'Unarmed Combat':15,
'Own Language':80,'Linguistics':35,'Bargain':35,'Psychology':28,'Fast Talk':22,
'Astronomy':62,'Biology':40,'Botany':32,'Chemistry':22,
'Computers':55,'Emergency Treatment':38,'History':45,
'Mathematics':42,'Physics':30,'Planetology':70,'Research':65,
'Strategy':28,'Zoology':45,
'Handgun Energy':40,'Handgun Projectile':35,'Listen':58,
'Observe':72,'Scent':18,'Search':65,'Track':55,
'Aquatic Vehicle':22,'Atmospheric Craft':48,'Ground Vehicle':45,
'Personal Flyer':35,'Repair':30,'Ringworld':22,
},
'weapons':[
('Sidearm Pistol','40%','1D8','20 / 80 / 250m','Standard survey issue'),
('Sonic Stunner','35%','Stun (non-lethal)','5 / 20 / 50m','Field non-lethal'),
],
'equip':['Survey instruments (full kit)','Portable mapping unit',
'Sidearm pistol','Sonic stunner','Geological sample kit','Climbing gear'],
'equip_detail':['Survey instruments (full kit)','Portable mapping unit',
'Sidearm pistol','Sonic stunner','Emergency flare gun × 4',
'Geological sample kit','Climbing gear (20m line + gear)',
'Emergency shelter'],
'personality':[
'"That shouldn\'t be there." Doesn\'t',
'talk much. Notices everything.',
'17 Ringworld surveys. Still looks',
'at the arc overhead every time.',
],
'armour': {}, # No armour
'hook95':['The quiet zone is a perfect geometrical absence — nothing matches it.',
'Your Awareness spots the overlapping footprints before anyone else.',
'In Act Three the system\'s display of your data is cartographic.',
'▸ KEY: Awareness · Navigation for zone tracking · Planetology'],
'hook96':['Survival (Ringworld) is the crew\'s critical skill when weather drops.',
'When Vorn gives the two-hour window, you calculate if it\'s achievable.',
'Your Navigation makes you the extraction planner.',
'▸ KEY: Survival (Ringworld) · Navigation · Awareness: Ghresh-Ka'],
},
]
# ── Generate PDF ──────────────────────────────────────────────────────────────
out_path = '/home/claude/ChaosiumCon26/apps/ringworld-character-sheets/character-sheets.pdf'
c = rl_canvas.Canvas(out_path, pagesize=A4)
total = len(CHARACTERS) * 2
for i, char in enumerate(CHARACTERS):
draw_front(c, char, i*2+1, total)
c.showPage()
draw_back(c, char, i*2+2, total)
c.showPage()
print(f"Done: {char['name']}")
c.save()
print(f"\nSaved: {out_path}")
import os
print(f"Size: {os.path.getsize(out_path):,} bytes")