diff --git a/README.md b/README.md index ca75d4f..3718afa 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/game.py b/game.py index b797899..5ce99f8 100644 --- a/game.py +++ b/game.py @@ -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 = { diff --git a/static/app.js b/static/app.js index 2183bda..19c832a 100644 --- a/static/app.js +++ b/static/app.js @@ -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) => ``; +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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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() {
Icons by Lorc, Delapouite and others at game-icons.net, CC BY 3.0
`; } function devbar() { @@ -391,7 +403,7 @@ function prayerPanel() { const temple = city.buildings.temple.level; return `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.
🙏 ${me.prayers.length ? `${me.prayers.map((id) => esc(C.prayers.find((p) => p.id === id).name)).join(" + ")} · ${fmt(me.pp)} points${me.blessing.cost ? "" : ` (not enough points: prayers are off)`}` : `No prayers active.`} +
${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 ? "" : ` (not enough points: prayers are off)`}` : `No prayers active.`}
Below half health you eat from your bank automatically. ${me.edible.length ? "" : `You have no food in your bank.`} @@ -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 = `
🪙 ${fmt(S.me.gold)}
+${COIN} ${fmt(S.me.gold)}
The treasury pays the gold part of new buildings. 1 favour per gold.
✨ ${fmt(me.favour)} favour · ${fmt(me.donated)} given in total
+${FAVOUR} ${fmt(me.favour)} favour · ${fmt(me.donated)} given in total
Favour can also hire builders, pay informants and rally the militia: see .
Couldn't reach the city: ${esc(err.message)}
`; diff --git a/static/icons.svg b/static/icons.svg new file mode 100644 index 0000000..5e70e6a --- /dev/null +++ b/static/icons.svg @@ -0,0 +1,98 @@ + diff --git a/static/styles.css b/static/styles.css index 83e0111..6e3c750 100644 --- a/static/styles.css +++ b/static/styles.css @@ -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); } diff --git a/tests/test_game.py b/tests/test_game.py index 1fdcd23..ef31d47 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -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() diff --git a/tools/build_icons.py b/tools/build_icons.py new file mode 100644 index 0000000..1d8090b --- /dev/null +++ b/tools/build_icons.py @@ -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'