Icons: game-icons.net glyphs tinted per tier, built into one sprite

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-19 10:04:38 +01:00
parent 2eed9b7ec4
commit 5cc5b98d61
7 changed files with 314 additions and 18 deletions

View file

@ -79,3 +79,9 @@ snapshot and exits; the NixOS module runs it nightly when
- `game.py`: content tables and the rules. Pure functions of `(world, now)`.
- `app.py`: stdlib HTTP server, persistence and the background ticker.
- `static/`: the client (vanilla JS, no build step).
## Credits
Icons are from [game-icons.net](https://game-icons.net) by Lorc, Delapouite
and contributors, used under [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/).
`tools/build_icons.py` fetches the glyphs the game uses into `static/icons.svg`.

124
game.py
View file

@ -2363,6 +2363,130 @@ def buy_perk(player: dict, perk: str) -> str | None:
return None
# --- icons ------------------------------------------------------------------------
# Every item, skill, class, building, prayer and output-less action gets a
# game-icons.net glyph (CC BY 3.0) and a tint. Families share a shape and are
# told apart by colour: a rune sword is the copper sword in cyan. The sprite
# with just these glyphs is built by tools/build_icons.py into static/icons.svg.
METAL_TINTS = {"copper": "#d07a3c", "iron": "#9aa1ab", "steel": "#d4dae2",
"mithril": "#6f8fec", "adamant": "#4cb878", "rune": "#3fd0e0"}
WOOD_TINTS = ["#b9844e", "#8f5e30", "#a3b86c", "#d0663a", "#6f9a4a", "#a77be8"]
LEATHER_TINTS = ["#b07a4a", "#8a5a32", "#6b4a33", "#5fae5a", "#4f86d6", "#d0503c"]
HERB_TINTS = {"guam_leaf": "#7fbf5a", "marrentill": "#e7c84a", "tarromin": "#d05a8a",
"harralander": "#f0b030", "ranarr": "#8a6fd0", "snapdragon": "#e86a9a"}
PIECE_GLYPHS = {"sword": "pointy-sword", "shield": "round-shield", "helmet": "visored-helm",
"legs": "leg-armor", "body": "breastplate"}
TOOL_GLYPHS = {"pickaxe": "war-pick", "axe": "wood-axe", "hoe": "sickle", "fishing rod": "fishing-pole",
"hunting knife": "bowie-knife", "hammer": "claw-hammer"}
LEATHER_GLYPHS = {"cap": "hood", "legs": "trousers", "body": "leather-vest"}
GLYPHS = {
"stone": ("stone-block", "#a8a29e"), "wheat": ("wheat", "#e3c060"), "vegetables": ("carrot", "#f08a3c"),
"feathers": ("feather", "#e8e8ee"), "raw_meat": ("meat", "#e06a6a"), "arrows": ("arrow-cluster", "#c9b28a"),
"bricks": ("brick-pile", "#c66a45"), "cut_stone": ("stone-stack", "#b8b2aa"), "beam": ("wood-beam", "#9a6a3a"),
"bread": ("bread", "#deb064"), "cooked_meat": ("ham-shank", "#cf8f50"), "stew": ("hot-meal", "#c9884a"),
"bones": ("broken-bone", "#e9dfc6"), "big_bones": ("crossed-bones", "#e9dfc6"),
"dragon_bones": ("dinosaur-bones", "#a6d98f"), "studded_body": ("leather-armor", "#a3825a"),
"coal": ("ore", "#5d626b"), "water_barrel": ("barrel", "#5aa0d8"),
"raw_shrimp": ("shrimp", "#f39a82"), "raw_trout": ("flatfish", "#9fb5c8"), "raw_salmon": ("salmon", "#f08a6a"),
"raw_eel": ("eel", "#7f9a6a"), "raw_lobster": ("crab", "#e05a3a"), "raw_swordfish": ("angler-fish", "#7fa3c8"),
"cooked_shrimp": ("shrimp", "#f0b07a"), "cooked_trout": ("fish-cooked", "#d9a060"),
"cooked_salmon": ("fish-cooked", "#f09a6a"), "smoked_eel": ("eel", "#b07a4a"),
"cooked_lobster": ("crab", "#f07a4a"), "cooked_swordfish": ("fish-cooked", "#c8a070"),
}
SKILL_GLYPHS = {
"mining": ("mining", "#b8b2aa"), "woodcutting": ("wood-axe", "#c98a4a"), "farming": ("wheat", "#e3c060"),
"hunting": ("deer-head", "#c49a6c"), "fishing": ("fishing-pole", "#5aa0d8"), "smithing": ("anvil", "#d07a3c"),
"masonry": ("brick-wall", "#c66a45"), "construction": ("hammer-nails", "#d1a46a"),
"cooking": ("cooking-pot", "#e0703a"), "leatherworking": ("sewing-needle", "#d6a26e"),
"herblore": ("cauldron", "#7fbf5a"), "combat": ("crossed-swords", "#e05a5a"), "prayer": ("prayer", "#e9dfc6"),
"reconnaissance": ("spyglass", "#6f8fec"),
}
CLASS_GLYPHS = {"warrior": ("black-knight-helm", "#c0c6d0"), "scout": ("bow-arrow", "#8fbf6a"),
"artisan": ("anvil-impact", "#e0a050"), "pioneer": ("compass", "#4fb3d9")}
BUILDING_GLYPHS = {
"houses": ("house", "#d0a060"), "walls": ("stone-wall", "#a8a29e"), "barracks": ("barracks-tent", "#c06a4a"),
"smithy": ("anvil", "#d07a3c"), "temple": ("temple-gate", "#e9dfc6"), "granary": ("granary", "#e3c060"),
"lumber_shed": ("wood-pile", "#b9844e"), "well": ("well", "#5aa0d8"), "spymaster": ("hooded-figure", "#8a7fd0"),
"storehouse": ("wooden-crate", "#b9844e"),
}
ACTION_GLYPHS = {
"fight_wolves": ("wolf-head", "#9aa1ab"), "fight_scouts": ("hooded-assassin", "#a08060"),
"fight_ruins": ("castle-ruins", "#a8a29e"), "fight_stronghold": ("castle", "#8a8f98"),
"fight_green_dragon": ("dragon-head", "#5fae5a"), "fight_blue_dragon": ("dragon-head", "#4f86d6"),
"fight_red_dragon": ("dragon-head", "#d0503c"), "fight_black_dragon": ("dragon-head", "#8a8f98"),
"sabotage": ("fire-bomb", "#e0703a"), "recon_roads": ("spyglass", "#6f8fec"), "recon_camps": ("spyglass", "#8a7fd0"),
"labour": ("hammer-nails", "#d1a46a"), "clear_wolves": ("wolf-head", "#9aa1ab"),
"catch_goblins": ("goblin-head", "#8fbf6a"), "drive_bear": ("bear-head", "#8a5a32"), "cull_boars": ("boar", "#a06a4a"),
"catch_rats": ("rat", "#9aa1ab"), "clear_rockslide": ("falling-rocks", "#a8a29e"),
}
PRAYER_GLYPHS = {"thick_skin": ("shield", "#a8a29e"), "burst_of_strength": ("biceps", "#e06a6a"),
"rapid_heal": ("heart-plus", "#e06a8a"), "steel_skin": ("armor-upgrade", "#d4dae2"),
"superhuman_strength": ("biceps", "#ff8a3a"), "second_wind": ("wind-hole", "#8fd0e0"),
"protection": ("aura", "#f0d060"), "piety": ("angel-wings", "#f0e6c0")}
UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield"]
def _assign_glyphs() -> None:
for item, (glyph, tint) in GLYPHS.items():
ITEMS[item].update(glyph=glyph, tint=tint)
for key, _name, ore, *_rest in METALS:
tint = METAL_TINTS[key]
if ore:
ITEMS[ore[0]].update(glyph="ore", tint=tint)
ITEMS[f"{key}_bar"].update(glyph="metal-bar", tint=tint)
for piece, glyph in PIECE_GLYPHS.items():
ITEMS[f"{key}_{piece}"].update(glyph=glyph, tint=tint)
for tool, glyph in TOOL_GLYPHS.items():
ITEMS[f"{key}_{tool.replace(' ', '_')}"].update(glyph=glyph, tint=tint)
for (log, *_tree), (plank, _name), tint in zip(TREES, PLANKS, WOOD_TINTS):
ITEMS[log].update(glyph="log", tint=tint)
ITEMS[plank].update(glyph="planks", tint=tint)
for (raw, leather, prefix, *_rest), tint in zip(LEATHERS, LEATHER_TINTS):
ITEMS[raw[0]].update(glyph="animal-hide", tint=tint)
ITEMS[leather[0]].update(glyph="rolled-cloth", tint=tint)
for suffix, glyph in LEATHER_GLYPHS.items():
ITEMS[f"{prefix}_{suffix}"].update(glyph=glyph, tint=tint)
for herb, tint in HERB_TINTS.items():
ITEMS[herb].update(glyph="herbs-bundle", tint=tint)
for potion, *_rest, skill, _effect in POTIONS:
if skill == "combat":
ITEMS[potion].update(glyph="potion-ball", tint="#e05a5a")
else:
ITEMS[potion].update(glyph="round-bottom-flask", tint=SKILL_GLYPHS[skill][1])
for skill in SKILLS:
skill["glyph"], skill["tint"] = SKILL_GLYPHS[skill["id"]]
for key, spec in CLASSES.items():
spec["glyph"], spec["tint"] = CLASS_GLYPHS[key]
for key, spec in BUILDINGS.items():
spec["glyph"], spec["tint"] = BUILDING_GLYPHS[key]
for prayer in PRAYERS:
prayer["glyph"], prayer["tint"] = PRAYER_GLYPHS[prayer["id"]]
for action in ACTIONS:
if action["id"] in ACTION_GLYPHS:
action["glyph"], action["tint"] = ACTION_GLYPHS[action["id"]]
elif action["outputs"]:
first = ITEMS[next(iter(action["outputs"]))]
action["glyph"], action["tint"] = first["glyph"], first["tint"]
elif action.get("special") in ("bury", "offer"):
bone = ITEMS[next(iter(action["inputs"]))]
action["glyph"] = "tombstone" if action["special"] == "bury" else "candle-holder"
action["tint"] = bone["tint"] if action["special"] == "bury" else "#f0c060"
else:
action["glyph"], action["tint"] = SKILL_GLYPHS[action["skill"]]
def all_glyphs() -> set[str]:
"""Every glyph the game draws, for building the sprite."""
names = set(UI_GLYPHS)
for collection in (ITEMS.values(), SKILLS, CLASSES.values(), BUILDINGS.values(), PRAYERS, ACTIONS):
names.update(entry["glyph"] for entry in collection)
return names
_assign_glyphs()
def content() -> dict:
"""The static tables the client renders from."""
buildings = {

View file

@ -19,6 +19,17 @@ let version = null; // server build this page was loaded from
let failures = 0; // polls in a row that didn't reach the server
const $ = (sel, root = document) => root.querySelector(sel);
// Icons are game-icons.net glyphs (CC BY 3.0) from one sprite, tinted per tier or material.
const glyph = (name, tint) => `<svg class="gi" style="color:${tint}" aria-hidden="true"><use href="/static/icons.svg#${name}"></use></svg>`;
const COIN = glyph("two-coins", "#e0b04a"), FAVOUR = glyph("sparkles", "#b98ce8"), HEART = glyph("hearts", "#e2645c");
function iconise(content) {
const entries = [...Object.values(content.items), ...content.skills, ...Object.values(content.classes),
...Object.values(content.buildings), ...content.prayers, ...content.actions];
for (const entry of entries) if (entry.glyph) entry.icon = glyph(entry.glyph, entry.tint);
return content;
}
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
const fmt = (n) => Math.floor(n).toLocaleString("en-GB");
const serverNow = () => Date.now() / 1000 + skew;
@ -125,7 +136,7 @@ function toast(text, kind = "") {
function showSummary(sum) {
if (!sum.done && !sum.stopped) return;
if (sum.done >= SUMMARY_MODAL_AT) return awayModal(sum);
const got = Object.entries(sum.items).map(([id, q]) => `+${fmt(q)} ${item(id).icon} ${item(id).name}`);
const got = Object.entries(sum.items).map(([id, q]) => `+${fmt(q)} ${item(id).name}`);
if (sum.gold) got.push(`+${fmt(sum.gold)} gold`);
if (got.length) toast(got.join(" · "), "good");
const eaten = Object.entries(sum.eaten || {});
@ -218,8 +229,8 @@ function sidebar() {
<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</small></div>
<div class="me">
<div class="who">${esc(me.name)}</div>
<div class="cls">${cls.icon} ${cls.name} · ❤️ ${fmt(me.hp)}/${me.max_hp}</div>
<div class="purse"><span class="gold" title="Gold">🪙 ${fmt(me.gold)}</span><span class="favour" title="Favour">✨ ${fmt(me.favour)}</span></div>
<div class="cls">${cls.icon} ${cls.name} · ${HEART} ${fmt(me.hp)}/${me.max_hp}</div>
<div class="purse"><span class="gold" title="Gold">${COIN} ${fmt(me.gold)}</span><span class="favour" title="Favour">${FAVOUR} ${fmt(me.favour)}</span></div>
</div>
<div class="navhead">City</div>
${nav("town", "🏘️", "Town square", `${S.city.population}`)}
@ -227,7 +238,7 @@ function sidebar() {
${nav("board", "💬", "Town board")}
${nav("roster", "👥", "Guild roster", `${S.players.length}`)}
<div class="navhead">You</div>
${nav("character", cls.icon, "Character", `⚔️ ${me.levels.combat}`)}
${nav("character", cls.icon, "Character", `${skillOf("combat").icon} ${me.levels.combat}`)}
${nav("bank", "🏦", "Bank", `${Object.keys(me.bank).length}/${me.slots}`)}`;
let group = null;
for (const skill of C.skills) {
@ -239,7 +250,8 @@ function sidebar() {
const busy = doing?.skill === skill.id ? `<span class="dot"></span>` : "";
html += nav("skill:" + skill.id, skill.icon, skill.name, `${me.levels[skill.id]}`, busy);
}
return html + (S.dev ? devbar() : "");
return html + (S.dev ? devbar() : "")
+ `<p class="credit">Icons by Lorc, Delapouite and others at <a href="https://game-icons.net" target="_blank" rel="noopener">game-icons.net</a>, CC BY 3.0</p>`;
}
function devbar() {
@ -391,7 +403,7 @@ function prayerPanel() {
const temple = city.buildings.temple.level;
return `<div class="cols">
<div class="panel"><h2>Prayers · ${me.prayers.length} / ${C.max_prayers} active</h2>
<div class="prayer-points">🙏 <b>${fmt(me.pp)}</b> prayer points${cost ? ` · ${cost} per fight, enough for about ${fmt(me.pp / cost)} fights` : ""}</div>
<div class="prayer-points">${skillOf("prayer").icon} <b>${fmt(me.pp)}</b> prayer points${cost ? ` · ${cost} per fight, enough for about ${fmt(me.pp / cost)} fights` : ""}</div>
<div class="prayers">${cards}</div>
<p class="small muted">Active prayers work in every fight while you have the points to pay for them, and each point spent trains Prayer. Bury bones for more points.</p>
</div>
@ -423,11 +435,11 @@ function combatPanel() {
const hpPct = me.hp / me.max_hp * 100;
return `<div class="cols">
<div class="panel"><h2>Health</h2>
<div class="hp-line"><b>❤️ ${fmt(me.hp)} / ${me.max_hp}</b><span class="muted small">recovers ${Math.round(C.regen_per_hour * 100)}% an hour</span></div>
<div class="hp-line"><b>${HEART} ${fmt(me.hp)} / ${me.max_hp}</b><span class="muted small">recovers ${Math.round(C.regen_per_hour * 100)}% an hour</span></div>
<div class="bar thick hp"><span style="width:${hpPct}%"></span></div>
<div class="row" style="margin-top:12px"><label class="small muted">Food in fights
<select data-food-pick>${`<option value="">Cheapest first</option>` + foods}</select></label></div>
<p class="small">🙏 ${me.prayers.length ? `${me.prayers.map((id) => esc(C.prayers.find((p) => p.id === id).name)).join(" + ")} · ${fmt(me.pp)} points${me.blessing.cost ? "" : ` <b class="warn">(not enough points: prayers are off)</b>`}` : `<span class="muted">No prayers active.</span>`}
<p class="small">${skillOf("prayer").icon} ${me.prayers.length ? `${me.prayers.map((id) => esc(C.prayers.find((p) => p.id === id).name)).join(" + ")} · ${fmt(me.pp)} points${me.blessing.cost ? "" : ` <b class="warn">(not enough points: prayers are off)</b>`}` : `<span class="muted">No prayers active.</span>`}
<button class="linkish" data-nav="skill:prayer">Prayers</button></p>
<p class="small muted">Below half health you eat from your bank automatically.
${me.edible.length ? "" : `<b class="warn">You have no food in your bank.</b>`}
@ -459,7 +471,7 @@ function actionCard(a) {
const why = me.blocked[a.id];
const locked = isLocked(a);
const out = Object.keys(a.outputs)[0];
const icon = out ? item(out).icon : skillOf(a.skill).icon;
const icon = a.icon;
const time = me.times[a.id];
let io = "";
if (Object.keys(a.inputs).length) {
@ -477,7 +489,7 @@ function actionCard(a) {
if (a.special === "recon") extra = `<div class="small muted">+${a.recon} recon on the next event</div>`;
const temple = S.city.buildings.temple?.level || 0;
if (a.special === "labour") extra = `<div class="small makes">🏗️ +${me.labour_per_action} labour per action</div>`;
if (a.special === "bury") extra = `<div class="small makes">🙏 +${a.pp + temple} prayer points</div>`;
if (a.special === "bury") extra = `<div class="small makes">${skillOf("prayer").icon} +${a.pp + temple} prayer points</div>`;
if (a.special === "offer") extra = `<div class="small makes">⛪ +${+(a.devotion * (1 + 0.25 * temple)).toFixed(1)} devotion for the city's ward</div>`;
const made = Object.keys(a.outputs).map(itemStats).filter(Boolean);
if (made.length) extra += `<div class="small makes">${made.join(" · ")}</div>`;
@ -576,7 +588,7 @@ function equipLabel(it) {
function goldPanel() {
return `<div class="panel"><h2>Gold</h2>
<p class="gold">🪙 ${fmt(S.me.gold)}</p>
<p class="gold">${COIN} ${fmt(S.me.gold)}</p>
<form data-form="gold" class="btns"><input type="number" min="1" data-keep="gold" placeholder="Amount" style="width:120px"><button class="btn gold">Donate to treasury</button></form>
<p class="small muted">The treasury pays the gold part of new buildings. 1 favour per gold.</p></div>`;
}
@ -584,7 +596,7 @@ function goldPanel() {
function favourPanel() {
const me = S.me;
return `<div class="panel"><h2>Favour with the council</h2>
<p class="favour">✨ ${fmt(me.favour)} favour · ${fmt(me.donated)} given in total</p>
<p class="favour">${FAVOUR} ${fmt(me.favour)} favour · ${fmt(me.donated)} given in total</p>
<div class="btns"><button class="btn" data-perk="bank_slot" ${me.favour < me.perk_costs.bank_slot ? "disabled" : ""}>+1 bank slot (${fmt(me.perk_costs.bank_slot)} favour)</button></div>
<p class="small muted">Favour can also hire builders, pay informants and rally the militia: see <button class="linkish" data-nav="council">Council &amp; threats</button>.</p></div>`;
}
@ -633,7 +645,7 @@ function siteBanner() {
<span class="muted small">${fmt(work.labour)} / ${fmt(work.needed)} labour</span>
<span class="btns">
${working ? `<span class="tag good">You're working on it</span>` : `<button class="btn sm primary" data-start="labour">Work on it</button>`}
${hire.why ? "" : `<button class="btn sm gold" data-favour="builders" ${me.favour < hire.cost ? "disabled" : ""} title="Adds half the labour that's left">Hire builders ✨ ${fmt(hire.cost)}</button>`}
${hire.why ? "" : `<button class="btn sm gold" data-favour="builders" ${me.favour < hire.cost ? "disabled" : ""} title="Adds half the labour that's left">Hire builders ${FAVOUR} ${fmt(hire.cost)}</button>`}
</span></div>
<div class="bar gold" style="margin-top:6px"><span style="width:${Math.min(100, work.labour / work.needed * 100)}%"></span></div>
<div class="small muted" style="margin-top:6px">${builders.length ? `On site: ${builders.join(", ")}. ` : ""}The townsfolk add about ${fmt(city.townsfolk_labour)} labour an hour.${queued ? ` Next: ${queued}.` : ""}</div>
@ -710,7 +722,7 @@ function buildingCard(key, b) {
const gold = id === "gold";
const mine = Math.min(missing, gold ? me.gold : me.bank[id] || 0);
const stores = Math.min(missing, gold ? city.treasury : city.stockpile[id] || 0);
const chip = costChip(gold ? "🪙" : item(id).icon, gold ? "Gold" : item(id).name, need, need - missing);
const chip = costChip(gold ? COIN : item(id).icon, gold ? "Gold" : item(id).name, need, need - missing);
return `<div class="fund-row">${chip}<span class="btns">
${mine ? `<button class="btn sm gold" data-fund="${key}" data-item="${id}" data-source="bank" title="From your ${gold ? "purse" : "bank"}, earns favour">Give ${fmt(mine)}</button>` : ""}
${stores ? `<button class="btn sm" data-fund="${key}" data-item="${id}" data-source="city" title="From the city's ${gold ? "treasury" : "stockpile"}">${gold ? "Treasury" : "Stockpile"} ${fmt(stores)}</button>` : ""}
@ -782,10 +794,10 @@ function favourSpends() {
<span class="ico">${icon}</span>
<span class="grow"><b>${title}</b><span class="small muted">${text}</span></span>
<span class="btns"><button class="btn sm gold" data-favour="${kind}" ${o.why || short ? "disabled" : ""}
title="${esc(o.why || (short ? "Not enough favour" : ""))}">✨ ${fmt(o.cost)}</button></span>
title="${esc(o.why || (short ? "Not enough favour" : ""))}">${FAVOUR} ${fmt(o.cost)}</button></span>
</div>`;
};
return `<div class="panel"><h2>Spend favour on the city · you have ✨ ${fmt(me.favour)}</h2>
return `<div class="panel"><h2>Spend favour on the city · you have ${FAVOUR} ${fmt(me.favour)}</h2>
${option("builders", "🏗️", "Hire extra builders", f.builders.why ? esc(f.builders.why) + "." : `Adds ${fmt(f.builders.labour)} labour to the current building, half of what's left.`)}
${option("informant", "🕵️", "Pay an informant", f.informant.why ? esc(f.informant.why) + "." : "One more level of intel on what's coming.")}
${option("militia", "📯", "Rally the militia", f.militia.why ? esc(f.militia.why) + "." : `+10 defence for the coming raid${f.militia.rallied ? ` (+${f.militia.rallied} so far)` : ""}. Costs more each time.`)}
@ -838,7 +850,7 @@ function roster() {
return `<div class="panel person"><div class="top"><span class="ico">${cls.icon}</span><div>
<b>${esc(p.name)}</b> <span class="muted small">${cls.name} · total level ${p.total}</span>
<div class="small">${p.doing ? `${skillOf(p.skill).icon} ${esc(p.doing)}` : `<span class="muted">Idle</span>`}${p.garrison ? ` · <span class="tag warn">On guard</span>` : ""}</div>
<div class="small favour">✨ ${fmt(p.donated)} given to the city</div></div></div>
<div class="small favour">${FAVOUR} ${fmt(p.donated)} given to the city</div></div></div>
<div class="levels">${levels}</div>
<p><button class="btn sm" data-nav="character:${esc(p.id)}">Character sheet</button></p></div>`;
}).join("")}</div>`;
@ -1127,7 +1139,7 @@ document.addEventListener("visibilitychange", () => { if (!document.hidden) refr
(async function boot() {
try {
C = await api("/api/content");
C = iconise(await api("/api/content"));
await refresh();
} catch (err) {
$("#main").innerHTML = `<p class="loading">Couldn't reach the city: ${esc(err.message)}</p>`;

98
static/icons.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 145 KiB

View file

@ -397,3 +397,10 @@ button.sheet-skill:hover { background: var(--panel-2); }
.skill-happenings { flex-direction: column; align-items: flex-start; gap: 4px; }
.msg-delete { background: none; border: 0; color: var(--muted); padding: 0 4px; margin-left: 4px; font-size: 12px; }
.msg-delete:hover { color: var(--bad); }
.gi { width: 1em; height: 1em; display: inline-block; vertical-align: -0.15em; flex: none; }
.item-chip .gi { width: 1.25em; height: 1.25em; vertical-align: -0.3em; }
.slot .gi { width: 34px; height: 34px; }
.action .ico .gi, .skill-head .big .gi, .sheet-head .big .gi { width: 70%; height: 70%; }
.nav .ic .gi { width: 20px; height: 20px; vertical-align: middle; }
.credit { margin: 14px 18px 0; font-size: 11.5px; color: var(--muted); }
.credit a { color: var(--muted); }

View file

@ -995,5 +995,15 @@ class HappeningsTest(unittest.TestCase):
self.assertIsNotNone(world["next_fortune_at"])
class IconTest(unittest.TestCase):
def test_everything_has_a_glyph_in_the_sprite(self):
sprite = (Path(__file__).resolve().parent.parent / "static" / "icons.svg").read_text()
missing = [name for name in game.all_glyphs() if f'id="{name}"' not in sprite]
self.assertEqual(missing, [], "run tools/build_icons.py")
for item, spec in game.ITEMS.items():
self.assertIn("glyph", spec, item)
if __name__ == "__main__":
unittest.main()

39
tools/build_icons.py Normal file
View file

@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Build static/icons.svg: one SVG sprite with every glyph the game uses.
The glyphs are from game-icons.net (CC BY 3.0, see README), fetched through
the Iconify API. Run this after adding a glyph name in game.py; the sprite is
committed, so the game itself never fetches anything at runtime.
python3 tools/build_icons.py
"""
import json
import sys
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
import game # noqa: E402
names = sorted(game.all_glyphs())
url = "https://api.iconify.design/game-icons.json?icons=" + ",".join(names)
request = urllib.request.Request(url, headers={"User-Agent": "guildhall-icon-builder"})
with urllib.request.urlopen(request, timeout=30) as response:
data = json.load(response)
if data.get("not_found"):
sys.exit(f"Not on game-icons.net: {', '.join(data['not_found'])}")
size = data.get("width", 512)
symbols = []
for name in names:
icon = data["icons"][name]
width, height = icon.get("width", size), icon.get("height", data.get("height", size))
symbols.append(f'<symbol id="{name}" viewBox="0 0 {width} {height}">{icon["body"]}</symbol>')
sprite = ('<svg xmlns="http://www.w3.org/2000/svg">\n'
'<!-- Icons from game-icons.net by Lorc, Delapouite and contributors, CC BY 3.0 -->\n'
+ "\n".join(symbols) + "\n</svg>\n")
(ROOT / "static" / "icons.svg").write_text(sprite)
print(f"Wrote {len(names)} glyphs to static/icons.svg ({len(sprite) // 1024} KiB)")