diff options
| author | Florian Fischer <florian.fischer@muhq.space> | 2025-06-08 20:34:11 -0500 |
|---|---|---|
| committer | Florian Fischer <florian.fischer@muhq.space> | 2025-07-03 22:01:22 -0400 |
| commit | 27f2934a62030eaf1bf6cc1f7f34802d3e1b86f3 (patch) | |
| tree | 66ec0ade0d9aa4b63a697d20f3eee327759ebfdd | |
| parent | b9cb10d93c802280f4bc7f9cb42bb6b596edcf10 (diff) | |
| download | muhqs-game-27f2934a62030eaf1bf6cc1f7f34802d3e1b86f3.tar.gz muhqs-game-27f2934a62030eaf1bf6cc1f7f34802d3e1b86f3.zip | |
add boss overview pages
| -rw-r--r-- | html/Makefile | 26 | ||||
| -rw-r--r-- | html/boss/ai-companion.html | 184 | ||||
| -rw-r--r-- | html/boss/index.md | 10 | ||||
| -rw-r--r-- | html/maps.md | 10 | ||||
| -rwxr-xr-x | scripts/generate_boss_html.py | 303 |
5 files changed, 526 insertions, 7 deletions
diff --git a/html/Makefile b/html/Makefile index 3519a80b..108a7fc6 100644 --- a/html/Makefile +++ b/html/Makefile @@ -45,8 +45,14 @@ EN_CARDS_LISTING := en/$(CARDS_LISTING) LANG_CARDS_LISTINGS := $(foreach card_listing, $(DE_CARDS_LISTING) $(EN_CARDS_LISTING), \ $(addprefix $(BUILDDIR)/, $(card_listing:.md=.html))) -SITES := index.md rules.md cards.md maps.md tools.html decks.md -HTML := $(addprefix $(BUILDDIR)/,$(SITES:.md=.html)) +GENERATE_BOSS := $(GAME_ROOT)/scripts/generate_boss_html.py + +BOSSES := kraken tyrant +BOSS_SITES := $(addprefix boss/,$(BOSSES:=.html)) +BOSS_SITES_HTML := $(addprefix $(BUILDDIR)/,$(BOSS_SITES)) + +SITES := index.md rules.md cards.md maps.md tools.html decks.md boss/ai-companion.html boss/index.html +HTML := $(addprefix $(BUILDDIR)/,$(SITES:.md=.html)) $(BOSS_SITES_HTML) BLOG_SOURCEDIR := $(HTML_ROOT)/blog BLOG_POSTS_MD := $(shell find $(BLOG_SOURCEDIR) -mindepth 1 -type f -name "*.md" -printf "%f\n") @@ -66,7 +72,7 @@ FILES_TO_COPY_TARGETS := $(addprefix $(BUILDDIR)/,$(FILES_TO_COPY)) all: $(HTML) $(RULES_HTML) maps misc $(BLOG_POSTS_HTML) misc: $(BUILDDIR)/latex-build $(BUILDDIR)/cards-data $(FILES_TO_COPY_TARGETS) \ - $(BUILDDIR)/webtools.wasm \ + $(BUILDDIR)/webtools.wasm $(BUILDDIR)/boss/ai-companion.wasm \ $(BUILDDIR)/feed.rss wasm_exec.js: $(shell go env GOROOT)/lib/wasm/wasm_exec.js @@ -138,9 +144,23 @@ $(eval $(call generateHtml, $(BUILDDIR),)) $(eval $(call generateRulesHtml, $(BUILDDIR)/rules, -N)) $(eval $(call generateHtml, $(BUILDDIR)/blog,)) +CARDS = $(notdir $(basename $(wildcard $(BUILDDIR)/cards-data/*/*.yml))) +GENCARDSDATALIST := echo $(CARDS) | python -c 'import sys; print("".join([f"<option value=\"{card}\"></option>" for card in sys.stdin.read().split()]))' +CARDSDATALIST := $(shell $(GENCARDSDATALIST)) +$(BUILDDIR)/boss/ai-companion.html: boss/ai-companion.html $(MAKEFILE_LIST) + @echo "building $@" + $(VERBOSE)if test \( ! \( -d $(@D) \) \); then mkdir -p $(@D); fi + $(VERBOSE) sed 's|%CARDS%|$(CARDSDATALIST)|' $< > $@ + FEEDSRC = feed GEN_FEED = $(FEEDSRC)/create_feed.py FEEDITEMS = $(wildcard $(FEEDSRC)/items/*) $(BUILDDIR)/feed.rss: $(FEEDITEMS) $(GEN_FEED) + @echo "building $$@" $(VERBOSE)python3 $(GEN_FEED) $(FEEDITEMS) > $@ + +$(BOSS_SITES_HTML): $(BUILDDIR)/boss/%.html: $(MAKEFILE_LIST) $(GENERATE_BOSS) $(GENERATE_HOVER_LINKS) + @echo "building $@" + @if test \( ! \( -d $(@D) \) \); then mkdir -p $(@D); fi + $(VERBOSE)$(GENERATE_BOSS) $(*F) $(DATA_ROOT) $(MAPS_ROOT) $(RULES_ROOT) > $@ diff --git a/html/boss/ai-companion.html b/html/boss/ai-companion.html new file mode 100644 index 00000000..65923543 --- /dev/null +++ b/html/boss/ai-companion.html @@ -0,0 +1,184 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" /> + <title>AI Companion</title> + + <script src="https://cdnjs.cloudflare.com/ajax/libs/eruda/3.4.1/eruda.js" integrity="sha512-3h7ROKDcr6JDuNeJM83xKEnx4cqMvii81NQ+9bSOwOTQL6AKhSmp5oVRL1R4rrM6H23CarCgDK3FIFl3ZnZEZA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> + <script>eruda.init();</script> + + <script src="../js/wasm_exec.js"></script> + + <style> +:root { + /* define applied colors */ + --bg: #fdfdfd; + --fg: #1a1a1a; +} + +@media (prefers-color-scheme: dark) { + /* change apllied colors to the dark gruvbox pallet + * https://github.com/morhetz/gruvbox + */ + :root { + --bg: #282828; /* dark bg */ + --fg: #ebdbb2; /* dark fg */ + } +} + +html { + line-height: 1.5; + font-family: Georgia, serif; + font-size: 20px; + color: var(--fg); + background-color: var(--bg); +} + +body { + margin: 0 auto; + max-width: 36em; + padding-left: 50px; + padding-right: 50px; + padding-top: 50px; + padding-bottom: 50px; + hyphens: auto; + word-wrap: break-word; + text-rendering: optimizeLegibility; + font-kerning: normal; +} + +@media (max-width: 600px) { + body { + font-size: 0.9em; + padding: 1em; + } +} +.card-remove-button { + margin: 5px; + border: solid; + border-radius: 1px; + background: gray; +} +</style> + + <datalist id="maps"> + <option value="the-kraken"></option> + <option value="the-tyrant"></option> + </datalist> + <datalist id="cards"> + %CARDS% + </datalist> + + <script> + if (!WebAssembly.instantiateStreaming) { // polyfill + WebAssembly.instantiateStreaming = async (resp, importObject) => { + const source = await (await resp).arrayBuffer(); + return await WebAssembly.instantiate(source, importObject); + }; + } + + const go = new Go(); + let mod, inst; + const wasm = "ai-companion.wasm" + + WebAssembly.instantiateStreaming(fetch(wasm), go.importObject).then((result) => { + mod = result.module; + inst = result.instance; + document.getElementById("runButton").disabled = false; + }).catch((err) => { + console.error(err); + }); + + var cards = {}; + for (const opt of document.getElementById("cards").options) { + cards[opt.value] = true; + } + + var players = {}; + + var ebitenArgs; + async function run() { + const map = document.getElementById("mapInput").value; + // global value retrieved by the go wasm + ebitenArgs = JSON.stringify({mapName: map, players: players.values}); + await go.run(inst); + inst = await WebAssembly.instantiate(mod, go.importObject); // reset instance + } + + function addCard(cardInput) { + const card = cardInput.value; + if (!cards[card]) return; + let playerDisplay = cardInput.parentNode; + const playerName = playerDisplay.id.slice(0, -4); + let player = players[playerName]; + let pcards = player.cards; + + console.log(playerName + " " + card); + pcards.push(card); + const cardIdx = pcards.length; + + let cardElem = document.createElement("div"); + cardElem.appendChild(document.createTextNode(card)); + let rmvBtn = document.createElement("span"); + rmvBtn.innerHTML = "x"; + rmvBtn.onclick = function() { + pcards.splice(cardIdx, 1); + playerDisplay.removeChild(cardElem); + }; + rmvBtn.classList.add("card-remove-button"); + cardElem.appendChild(rmvBtn); + playerDisplay.appendChild(cardElem); + } + + function addPlayer() { + let nameInput = document.getElementById("playerName"); + const name = nameInput.value; + players[name] = {name: name, cards: []} + + nameInput.value = ""; + + let player = document.createElement("div"); + player.id = name; + player.innerHTML = `${name} +<div id="${name}Deck"> +<input list="cards" oninput="addCard(this)"> +</input> +</div>`; + document.getElementById("players").appendChild(player); + } + + function prefillInputsFromUrl() { + const queryParams = window.location.search.substring(1).split("?"); + for (const param of queryParams) { + let parts = param.split("=") + let key = parts[0] + let value = parts[1] + + let input = document.getElementById(key); + if (input) { + input.value = value; + } + } + } +window.onload = function() { + prefillInputsFromUrl(); +} + </script> +</head> + +<body> + <h1>AI Companion</h1> + <label for="mapInput">Map:</label> + <input autocomplete="on" id="mapInput" list="maps"></input> + <br> + + <label for="playerName">Player name:</label> + <input id="playerName"></input> + <button onclick="addPlayer()">add</button> + + <div id="players"></div> + + <button onClick="run();" id="runButton" disabled>Run</button> +</body> +</html> diff --git a/html/boss/index.md b/html/boss/index.md new file mode 100644 index 00000000..ec3c7776 --- /dev/null +++ b/html/boss/index.md @@ -0,0 +1,10 @@ +%Bosses + +For muhq's game there exist multiple [maps](../maps.html#coop) offering a cooperative game experience battling an AI opponent. + +## Available Bosses + +The following Bosses are available: + +* The Kraken - a simple AI opponent [[map](../maps.html#the-kraken)] [[overview](kraken.html)] [[ai-companion](ai-companion.html?mapInput=the-kraken)] +* The Tyrant - a sophisticated AI altering the map [[map](../maps.html#the-tyrant)] [[overview](tyrant.html)] [[ai-companion](ai-companion.html?mapInput=the-tyrant)] diff --git a/html/maps.md b/html/maps.md index 8eda748c..1a9b2c9d 100644 --- a/html/maps.md +++ b/html/maps.md @@ -4,10 +4,10 @@ Tile definitions can be found in the [rules](rules.html) ## Available Maps -### Coop +### Coop vs. an AI [*Boss*](boss/) -* The Kraken [[yaml](maps/the-kraken.yml)] [[png](maps/the-kraken.png)] -* The Tyrant [[yaml](maps/the-tyrant.yml)] [[png](maps/the-tyrant.png)] +* The Kraken [[yaml](maps/the-kraken.yml)] [[png](maps/the-kraken.png)] [[boss](boss/kraken.html)] +* The Tyrant [[yaml](maps/the-tyrant.yml)] [[png](maps/the-tyrant.png)] [[boss](boss/tyrant.html)] ### 2 Players @@ -22,4 +22,6 @@ Tile definitions can be found in the [rules](rules.html) * Islands [[yaml](maps/4P-islands.yml)] [[png](maps/4P-islands.png)] -"Dolle karte die leg ich mal da hin" +:::{lang=de} + "Dolle Karte, die leg ich mal da hin" +::: diff --git a/scripts/generate_boss_html.py b/scripts/generate_boss_html.py new file mode 100755 index 00000000..46ebd15f --- /dev/null +++ b/scripts/generate_boss_html.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +"""Generate html page of an AI boss opponent""" + +# TODO: support multiple languages + +import argparse +from pathlib import Path +from string import Template +import re +import yaml + +from data import name2file +import generate_card_hover_links + +SETS = {"kraken": "kraken", "tyrant": "tyrant"} +NAMES = {"kraken": "The Kraken", "tyrant": "The Tyrant"} +DESCS = { + "kraken": + "Face the evil of the sea. Overcome the kraken, the fierce epicenter of the ozean, which employs creatures of the depth to stop your offense.<br>Since the kraken does not move and can not win it is the perfect boss to start.", + "tyrant": "" # TODO +} + +TEMPLATE = """<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml" lang="$lang" xml:lang="$lang"> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" /> + <title>${boss_name}</title> + <style> +:root { + /* define applied colors */ + --bg: #fdfdfd; + --fg: #1a1a1a; + --link-color: #1a1a1a; + --pre-bg: #fdfdfd; +} +@media (prefers-color-scheme: dark) { + /* change apllied colors to the dark gruvbox pallet + * https://github.com/morhetz/gruvbox + */ + :root { + --bg: #282828; /* dark bg */ + --fg: #ebdbb2; /* dark fg */ + --link-color: #d79921; /* yellow */ + --pre-bg: #1d2021; /* dark bg0_h */ + } +} +html { + line-height: 1.5; + font-family: Georgia, serif; + font-size: 20px; + color: var(--fg); + background-color: var(--bg); +} +body { + margin: 0 auto; + max-width: 36em; + padding: 50px; + hyphens: auto; + word-wrap: break-word; + text-rendering: optimizeLegibility; + font-kerning: normal; +} +.main { + //display: flex; + //flex-flow: row wrap; + display: grid; + grid-template-columns: 3fr 2fr; + grid-template-rows: 200px auto + + max-width: 100%; + overflow:hidden; +} +.intro { + grid-column-start: 1; + grid-row-start: 1; +} +.card { + margin-top: 2em; + grid-column-start: 2; + grid-row-start: 1; + grid-row-end: 3; +} +.content { + grid-column-start: 1; + grid-row-start: 2; +} +@media (max-width: 600px) { + body { + font-size: 0.9em; + padding: 1em; + } + .main { + grid-template-columns: 1fr + } + .card { + margin-top: 0; + grid-column-start: 1; + grid-row-start: 2; + } + .content { + grid-row-start: 3; + } +} +@media print { + body { + background-color: transparent; + color: black; + font-size: 12pt; + } + p, h2, h3 { + orphans: 3; + widows: 3; + } + h2, h3 { + page-break-after: avoid; + } +} +.content { + overflow:hidden; +} +p { + margin: 1em 0; +} +a { + color: var(--link-color); +} +a:visited { + color: var(--link-color); +} +img { + max-width: 100%; +} +h1, h2, h3 { + margin-top: 1.4em; +} +ol, ul { + padding-left: 1.7em; + margin-top: 1em; +} +li > ol, li > ul { + margin-top: 0; +} +code { + font-family: Menlo, Monaco, 'Lucida Console', Consolas, monospace; + padding: .2em .4em; + font-size: 85%; + margin: 0; + white-space: pre-wrap; +} +pre { + margin: 0; + background-color: var(--pre-bg); + //padding: 1em; + display: block; + overflow: auto; +} +pre > code { + white-space: pre; +} +pre code { display: inline-block; } +header { + margin-bottom: 4em; + text-align: center; +} +#draft-notation-hint { + font-size: 20px; + margin-left: 5px; + padding: 1px; + border: 0.5px solid var(--link-color); + border-radius: 100%; +} + </style> + +</head> +<body> +<div id="main" class="main"> +<div class="intro"> +<h2 class="title">${boss_name}</h2> +<a href="../$lang/cards_listing.html#${set_name}">cards</a>[<a href="../latex-build/$lang/${set_name}.pdf">pdf</a>] +<a href="../maps/${map_name}.png">map</a> +<a href="ai-companion.html?mapInput=${map_name}">ai-companion</a> +<p id="desc">${desc}</p> +</div> +<img class="card" src="../latex-build/$lang/${boss_card}.png"/> +<div class="content"> +<h3 id="wincon">Win Condition</h3> +<p>${win_condition}</p> +<h3 id="start-deck">Starting Deck</h3> +<p>${start_deck}</p> +<h3 id="draft">Recommended Draft <a id="draft-notation-hint" href="../rules/$lang/rules.html#notation">?</a></h3> +<p><code>${draft}</code></p> +<h3 id="ai">Instructions</h3> +<p><pre><code>${instructions}</code></pre></p> +</div> +</div> <!-- main --> +</body> +</html> +""" + + +def name_to_map(name: str) -> str: + """Return the map's file name of a boss""" + return name.lower().replace(" ", "-") + + +def wincon_desc(map_def: dict) -> str: + """Format a map's win condition""" + wincon = map_def['win_condition'] + if isinstance(wincon, str): + return wincon.capitalize() + + s = "" + for player, wc in wincon.items(): + s += f'<li><b>{player.capitalize()}</b>: {wc.capitalize()}</li>' + return f'<ul>{s}</ul>' + + +def recommended_draft(map_def: dict) -> str: + """Format the map's recommended draft""" + # TODO: improve default draft + # TODO: format multiple recommendations + return map_def.get('draft', '3x[2;8]') + + +def ai_instruction(name: str, rules, lang: str) -> str: + """Extract the boss' instructions from the rules""" + rules_path = Path(rules) / lang / 'ai.md' + with open(rules_path, 'r', encoding='utf-8') as rf: + rules = rf.read() + + p = f'#.*{name}.*\n' + ai_start = re.findall(p, rules) + ai_start = ai_start[0] + + rules = rules[rules.find(ai_start) + len(ai_start):] + # rule taken from mdextractor + instructions = re.findall(r"```(?:\w+\s+)?(.*?)```", rules, re.DOTALL) + return instructions[0].strip() + + +def gen_startdeck_ul(map_def: dict, lang: str) -> str: + if 'start_deck_list' in map_def: + dl = map_def["start_deck_list"].splitlines() + else: + dl = ['3 misc/farmer'] + + s = "" + for card in dl: + cardFmt = "" + parts = card.split() + if len(parts) > 1: + cardFmt = f'{parts[0]} ' + c = parts[1] + else: + c = card + + def gen_link_target(c, lang): + return generate_card_hover_links.gen_link_to_cardlisting( + c, lang, path_prefix='../') + + hlink = generate_card_hover_links.gen_hoverable_link( + c, lambda _: c, gen_link_target, '../latex-build/', lang) + cardFmt += hlink + s += f'\n<li>{cardFmt}</li>' + return f'<ul id="start-decklist">{s}\n</ul>' + + +def main(): + # pylint: disable=W0641 + """Generate a boss page""" + + parser = argparse.ArgumentParser(description='generate a boss html page') + parser.add_argument('name', + help='the name of the boss', + choices=['kraken', 'tyrant']) + parser.add_argument('data', help='directory containing the card data') + parser.add_argument('maps', help='directoey containing the map data') + parser.add_argument('rules', help='directoey containing the rules') + + args = parser.parse_args() + + lang = 'en' + boss_name = NAMES[args.name] + desc = DESCS[args.name] + set_name = SETS[args.name] + boss_card = f'{set_name}/{name2file(boss_name)}' + + map_name = name_to_map(boss_name) + map_path = Path(args.maps) / f'{map_name}.yml' + with open(map_path, 'r', encoding='utf-8') as map_file: + map_def = yaml.safe_load(map_file) + + win_condition = wincon_desc(map_def) + start_deck = gen_startdeck_ul(map_def, lang) + draft = recommended_draft(map_def) + instructions = ai_instruction(boss_name, args.rules, lang) + + print(Template(TEMPLATE).substitute(locals())) + + +if __name__ == '__main__': + main() |
