Add WIP crafting table rendering

This commit is contained in:
object-Object 2023-10-01 02:15:01 -04:00
parent aa7d927e28
commit 0f6050fcca
35 changed files with 440 additions and 148 deletions

9
.vscode/launch.json vendored
View file

@ -16,9 +16,14 @@
"name": "Python: Generate Docs",
"type": "python",
"request": "launch",
"module": "hexdoc.scripts.hexdoc",
"module": "hexdoc.cli.main",
"args": [
"doc/properties.toml", "-o", "out", "--lang", "en_us",
"render",
"doc/properties.toml",
"_site/src/docs",
"--lang",
"en_us",
"--allow-missing",
],
"console": "integratedTerminal",
"justMyCode": false

View file

@ -1,5 +1,6 @@
{
"watch": ["doc/src/hexdoc/_templates"],
"ext": "jinja,html,css,js",
"watch": ["doc/src/hexdoc/_templates", "doc/properties.toml"],
"ignore": ["**/generated/**"],
"ext": "jinja,html,css,js,toml,json,json5",
"exec": "hexdoc serve doc/properties.toml --src _site/src/docs --dst _site/dst/docs --lang en_us --allow-missing --release"
}

View file

@ -26,6 +26,35 @@ regex = "{^_pattern_regex}"
path = "{^_fabric.package}/FabricHexInitializer.kt"
regex = "{^_pattern_regex}"
[minecraft_assets]
# https://github.com/PrismarineJS/minecraft-assets/tree/83e2169afbbce40990d69fc53e5962e4a793d467/data/1.19.1
ref = "83e2169afbbce40990d69fc53e5962e4a793d467"
version = "1.19.1"
[textures]
missing = [
"farmersdelight:skillet",
"hexcasting:*colorizer*",
"hexcasting:amethyst_sconce",
"hexcasting:edified_button",
"hexcasting:edified_pressure_plate",
"hexcasting:edified_slab",
"hexcasting:edified_stairs",
]
[textures.override]
"hexcasting:akashic_connector" = "hexcasting:akashic_ligature"
"hexcasting:directrix/empty" = "hexcasting:circle/directrix/empty/front_dim"
"hexcasting:focus" = "hexcasting:focus_empty"
"hexcasting:impetus/empty" = "hexcasting:circle/impetus/empty/front_dim"
"hexcasting:quenched_allay_shard" = "hexcasting:quenched_shard_0"
"hexcasting:scroll_large" = "hexcasting:scroll_pristine_large"
"hexcasting:scroll_medium" = "hexcasting:scroll_pristine_medium"
"hexcasting:scroll_small" = "hexcasting:scroll_pristine_small"
"hexcasting:scroll" = "hexcasting:scroll_pristine_large"
"hexcasting:slate_block" = "hexcasting:slate"
"hexcasting:spellbook" = "hexcasting:spellbook_empty"
"hexcasting:staff/quenched" = "hexcasting:staff/quenched_0"
[template]
static_dir = "static"

View file

@ -38,23 +38,16 @@
not_supported: "Your browser does not support visualizing patterns. Pattern code: {}",
},
recipe: {
show: "Click to show recipe: {}",
hide: "Click to hide recipe: {}",
},
pages: {
patchouli: {
crafting: {
description: "Depicted in the book: The crafting recipe for the",
separator: " and ",
},
},
hexcasting: {
crafting_multi: {
description: "Depicted in the book: Several crafting recipes, for the",
separator: ", ",
},
brainsweep: {
description: "Depicted in the book: A mind-flaying recipe producing the",
separator: "", // this value intentionally left blank
separator: "",
},
},
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 B

View file

@ -1,8 +1,8 @@
{% import "common/macros.html.jinja" as macros %}
{% import "macros/formatting.html.jinja" as formatting %}
<section id="{{ category.id.path }}">
{% call macros.maybe_spoilered(category) %}
{{- macros.section_header(category, "h2", "category-title") }}
{% call formatting.maybe_spoilered(category) %}
{{- formatting.section_header(category, "h2", "category-title") }}
{{ category.description|hexdoc_block }}
{% endcall %}

View file

@ -1,4 +1,4 @@
{% import "common/macros.html.jinja" as macros -%}
{% import "macros/formatting.html.jinja" as formatting -%}
{# put the link target outside of toc-container so jump to top still works in sidebar mode #}
<div id="table-of-contents"></div>
@ -9,18 +9,18 @@
class="permalink toggle-link small"
data-target="toc-category"
title="{{ _('hexdoc.toc.toggle_all') }}"
><i class="bi bi-list-nested"></i></a>{{ macros.permalink("table-of-contents", "toc-permalink") }}</span>
><i class="bi bi-list-nested"></i></a>{{ formatting.permalink("table-of-contents", "toc-permalink") }}</span>
</h2>
{% for category in book.categories.values() if category.entries.values() %}
<details class="toc-category">
{# category #}
<summary>{{ macros.maybe_spoilered_link(category) }}</summary>
<summary>{{ formatting.maybe_spoilered_link(category) }}</summary>
{# list of entries in the category #}
<ul>
{% for entry in category.entries.values() %}
<li>{{ macros.maybe_spoilered_link(entry) }}</li>
<li>{{ formatting.maybe_spoilered_link(entry) }}</li>
{% endfor %}
</ul>
</details>

View file

@ -1,8 +1,8 @@
{% import "common/macros.html.jinja" as macros -%}
{% import "macros/formatting.html.jinja" as formatting -%}
<div id="{{ entry.id.path }}">
{% call macros.maybe_spoilered(entry) %}
{{- macros.section_header(entry, "h3", "entry-title") }}
{% call formatting.maybe_spoilered(entry) %}
{{- formatting.section_header(entry, "h3", "entry-title") }}
{% for page in entry.pages +%}
{% include page.template %}

View file

@ -38,15 +38,3 @@
{{ caller() }}
{% endif %}
{%- endmacro %}
{# shows the names of all the recipe results in a list of recipes #}
{% macro recipe_block(recipes, result_attribute, description, separator) -%}
<blockquote class="crafting-info">
{{ description }} {{
recipes
|map(attribute="result." ~ result_attribute)
|map("hexdoc_wrap", "code")
|join(separator)
}}.
</blockquote>
{%- endmacro %}

View file

@ -0,0 +1,64 @@
{# show the names of all the recipe results in a list of recipes #}
{% macro generic(recipes, result_attribute, description, separator) -%}
<blockquote class="crafting-info">
{{ description }} {{
recipes
|map(attribute="result."~result_attribute)
|map("hexdoc_wrap", "code")
|join(separator)
}}.
</blockquote>
{%- endmacro %}
{# display a single item, with a badge if the count is greater than 1 #}
{% macro render_item(item, count=1, always_show_count=false) -%}
<div data-toggle="tooltip" title="{{ item.name }}">
<img loading="lazy" alt="Image of {{ item.name }}" src="{{ item.texture }}">
{% if always_show_count or count > 1 %}
<div class="badge">{{ count }}</div>
{% endif %}
</div>
{%- endmacro %}
{% macro render_ingredient(ingredient) -%}
{% if ingredient is sequence %}
{# TODO: properly handle multi-option ingredients #}
{{ render_ingredient(ingredient[0]) }}
{% elif ingredient is none %}
{# air #}
<div></div>
{% elif ingredient.type|string == "hexcasting:mod_conditional" %}
{{ render_ingredient(ingredient.default) }}
{% else %}
{{ render_item(ingredient.item) }}
{% endif %}
{%- endmacro %}
{# render a crafting table for each recipe #}
{% macro crafting_table(recipes) -%}
{% for recipe in recipes %}
{% if loop.index0 > 0 %}<br />{% endif %}
<details class="details-collapsible">
<summary class="collapse-details">
<span class="collapse-recipe-show">{{ _('hexdoc.recipe.show').format(recipe.result.item.name) }}</span>
<span class="collapse-recipe-hide">{{ _('hexdoc.recipe.hide').format(recipe.result.item.name) }}</span>
</summary>
<div class="crafting-table">
<img
alt="Crafting table"
src="{{ 'hexcasting:textures/gui/hexdoc/crafting_table.png'|hexdoc_texture }}"
>
<div class="crafting-table-grid">
{% for item in recipe.ingredients %}
{{ render_ingredient(item) }}
{% endfor %}
</div>
<div class="crafting-table-result">
{{ render_item(recipe.result.item, recipe.result.count) }}
</div>
</div>
</details>
{% endfor %}
{%- endmacro %}

View file

@ -2,7 +2,7 @@ summary {
display: list-item;
}
details.spell-collapsible {
.details-collapsible {
display: inline-block;
border: 1px solid #aaa;
border-radius: 4px;
@ -10,31 +10,89 @@ details.spell-collapsible {
margin-bottom: 0.5em;
}
summary.collapse-spell {
.collapse-details {
font-weight: bold;
margin: -0.5em -0.5em 0;
padding: 0.5em;
}
details.spell-collapsible[open] {
.details-collapsible[open] {
padding: 0.5em;
}
details[open] summary.collapse-spell {
.details-collapsible[open] .collapse-details {
border-bottom: 1px solid #aaa;
margin-bottom: 0.5em;
}
details .collapse-spell::before {
.details-collapsible .collapse-details.collapse-spell::before {
content: "{{ _('hexdoc.pattern.show') }}";
}
details[open] .collapse-spell::before {
.details-collapsible[open] .collapse-details.collapse-spell::before {
content: "{{ _('hexdoc.pattern.hide') }}";
}
blockquote.crafting-info {
font-size: inherit;
/* if the collapsible is closed, hide the "hide recipe" message */
.details-collapsible:not([open]) > .collapse-details > .collapse-recipe-hide {
display: none;
visibility: hidden;
}
/* if the collapsible is open, hide the "show recipe" message */
.details-collapsible[open] > .collapse-details > .collapse-recipe-show {
display: none;
visibility: hidden;
}
/* we do a bit of crafting (mostly stolen from https://computercraft.info/wiki) */
.crafting-table {
position: relative;
width: 256px;
height: 132px;
background-color: #c6c6c6;
border-radius: 16px;
}
.crafting-table > img { /* background */
image-rendering: pixelated;
width: 100%;
height: 100%;
}
.crafting-table-grid {
position: absolute;
top: 14px;
left: 14px;
display: grid;
grid: repeat(3, 32px) / repeat(3, 32px);
gap: 4px;
}
.crafting-table-result {
position: absolute;
top: 50px;
left: 202px;
}
.crafting-table-result .badge {
position: absolute;
background-color: #292929;
bottom: -4px;
right: -4px;
}
:is(.crafting-table-grid, .crafting-table-result) :is(img, span) {
image-rendering: pixelated;
width: 32px;
height: 32px;
}
.tooltip {
white-space: nowrap;
}
a.permalink {

View file

@ -297,8 +297,7 @@ function hookSpoiler(elem) {
}
// these are filled by Jinja
const ROOT_URL = "{{ props.url }}";
const PAGE_URL = "{{ page_url }}";
const BOOK_URL = "{{ props.url }}";
const VERSION = "{{ version }}";
const LANG = "{{ lang }}";
@ -325,7 +324,7 @@ function versionDropdownItem(sitemap, version) {
path = defaultPath;
}
return dropdownItem(version, ROOT_URL + path);
return dropdownItem(version, BOOK_URL + path);
}
function versionDropdownItems(sitemap, versions) {
@ -390,7 +389,7 @@ function addDropdowns(sitemap) {
const langs = Object.keys(langPaths).sort();
document.getElementById("lang-dropdown").append(
...langs.map((lang) => dropdownItem(lang, ROOT_URL + langPaths[lang])),
...langs.map((lang) => dropdownItem(lang, BOOK_URL + langPaths[lang])),
);
// return sitemap for chaining, i guess
@ -399,12 +398,12 @@ function addDropdowns(sitemap) {
document.addEventListener("DOMContentLoaded", () => {
// fetch the sitemap from the root and use it to generate the navbar
fetch(`${ROOT_URL}/meta/sitemap.json`)
fetch(`${BOOK_URL}/meta/sitemap.json`)
.then(r => r.json())
.then(addDropdowns)
.catch(e => console.error(e))
document.querySelectorAll("details.spell-collapsible").forEach(hookLoad);
document.querySelectorAll(".details-collapsible").forEach(hookLoad);
document.querySelectorAll("a.toggle-link").forEach(hookToggle);
document.querySelectorAll(".spoilered").forEach(hookSpoiler);
@ -417,3 +416,7 @@ document.addEventListener("DOMContentLoaded", () => {
}
requestAnimationFrame((t) => tick(t, t));
});
$(function () {
$('[data-toggle="tooltip"]').tooltip()
})

View file

@ -1,8 +1,8 @@
{% extends "pages/patchouli/page.html.jinja" %}
{% include "common/macros.html.jinja" with context %}
{% import "macros/recipes.html.jinja" as recipe_macros with context %}
{% block body %}
{{ macros.recipe_block(
{{ recipe_macros.generic(
[page.recipe],
"name",
_("hexdoc.pages.hexcasting.brainsweep.description"),

View file

@ -1,12 +1,7 @@
{% extends "pages/patchouli/text.html.jinja" %}
{% include "common/macros.html.jinja" with context %}
{% import "macros/recipes.html.jinja" as recipe_macros with context %}
{% block inner_body %}
{{ macros.recipe_block(
page.recipes,
"item",
_("hexdoc.pages.hexcasting.crafting_multi.description"),
_("hexdoc.pages.hexcasting.crafting_multi.separator"),
) }}
{{ recipe_macros.crafting_table(page.recipes) }}
{{ super() }}
{% endblock inner_body %}

View file

@ -1,8 +1,8 @@
{% extends "pages/patchouli/text.html.jinja" %}
{% block inner_body %}
<details class="spell-collapsible">
<summary class="collapse-spell"></summary>
<details class="details-collapsible">
<summary class="collapse-details collapse-spell"></summary>
{% for pattern in page.patterns %}
<canvas
class="spell-viz"

View file

@ -1,12 +1,7 @@
{% extends "pages/patchouli/text.html.jinja" %}
{% include "common/macros.html.jinja" %}
{% import "macros/recipes.html.jinja" as recipe_macros with context %}
{% block inner_body %}
{{ macros.recipe_block(
page.recipes,
"item",
_("hexdoc.pages.patchouli.crafting.description"),
_("hexdoc.pages.patchouli.crafting.separator"),
) }}
{{ recipe_macros.crafting_table(page.recipes) }}
{{ super() }}
{% endblock inner_body %}

View file

@ -3,7 +3,7 @@
{% block inner_body %}
<p class="img-wrapper">
{% for image in page.images %}
<img src="{{ image|hexdoc_texture_url }}"></img>
<img src="{{ image|hexdoc_texture }}"></img>
{% endfor %}
</p>
{{ super() }}

View file

@ -1,5 +1,5 @@
{% extends "pages/patchouli/page.html.jinja" %}
{% import "common/macros.html.jinja" as macros %}
{% import "macros/formatting.html.jinja" as macros %}
{% block body %}
{% if page.title is not none %}

View file

@ -26,7 +26,7 @@ RequiredPathOption = Annotated[Path, typer.Option()]
UpdateLatestOption = Annotated[bool, typer.Option(envvar="UPDATE_LATEST")]
ReleaseOption = Annotated[bool, typer.Option(envvar="RELEASE")]
app = typer.Typer()
app = typer.Typer(pretty_exceptions_enable=False)
@app.command()
@ -158,11 +158,10 @@ def serve(
):
book_path = dst.resolve().relative_to(Path.cwd())
base_url = f"http://localhost:{port}"
book_url = f"{base_url}/{book_path.as_posix()}"
book_url = f"/{book_path.as_posix()}"
os.environ |= {
"DEBUG_GITHUBUSERCONTENT": base_url,
"DEBUG_GITHUBUSERCONTENT": "",
"GITHUB_PAGES_URL": book_url,
}
@ -186,7 +185,9 @@ def serve(
release=release,
)
print(f"Serving web book at {book_url} (press ctrl+c to exit)\n")
print(
f"Serving web book at http://localhost:{port}{book_url} (press ctrl+c to exit)\n"
)
with HTTPServer(("", port), SimpleHTTPRequestHandler) as httpd:
httpd.serve_forever()

View file

@ -21,7 +21,7 @@ from hexdoc.utils.jinja_extensions import (
IncludeRawExtension,
hexdoc_block,
hexdoc_localize,
hexdoc_texture_url,
hexdoc_texture,
hexdoc_wrap,
)
from hexdoc.utils.path import write_to_path
@ -50,7 +50,7 @@ def create_jinja_env(props: Properties):
"hexdoc_block": hexdoc_block,
"hexdoc_wrap": hexdoc_wrap,
"hexdoc_localize": hexdoc_localize,
"hexdoc_texture_url": hexdoc_texture_url,
"hexdoc_texture": hexdoc_texture,
}
return env

View file

@ -4,8 +4,10 @@ __all__ = [
"LocalizedStr",
"Recipe",
"Tag",
"RenderedItemStack",
]
from .assets import RenderedItemStack
from .i18n import I18n, LocalizedItem, LocalizedStr
from .recipe import Recipe
from .tags import Tag

View file

@ -0,0 +1,82 @@
import logging
from typing import Self
import requests
from pydantic import Field, ValidationInfo, model_validator
from pydantic.dataclasses import dataclass
from hexdoc.minecraft.i18n import I18nContext, LocalizedStr
from hexdoc.utils import ItemStack, ResourceLocation
from hexdoc.utils.deserialize import cast_or_raise
from hexdoc.utils.model import DEFAULT_CONFIG
from hexdoc.utils.resource_loader import resolve_texture_from_metadata
# 16x16 hashtag icon for tags
TAG_TEXTURE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAC4jAAAuIwF4pT92AAAANUlEQVQ4y2NgGJRAXV39v7q6+n9cfGTARKllFBvAiOxMUjTevHmTkSouGPhAHA0DWnmBrgAANLIZgSXEQxIAAAAASUVORK5CYII="
MISSING_TEXTURE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAC4jAAAuIwF4pT92AAAAJElEQVQoz2NkwAF+MPzAKs7EQCIY1UAMYMQV3hwMHKOhRD8NAPogBA/DVsDEAAAAAElFTkSuQmCC"
class MinecraftAssetsContext(I18nContext):
minecraft_textures: dict[ResourceLocation, str | None] = Field(default_factory=dict)
@model_validator(mode="after")
def _fetch_minecraft_textures(self) -> Self:
asset_props = self.props.minecraft_assets
url = (
"https://raw.githubusercontent.com/PrismarineJS/minecraft-assets"
f"/{asset_props.ref}/data/{asset_props.version}/texture_content.json"
)
logging.getLogger(__name__).info(f"Fetch textures from {url}")
resp = requests.get(url)
resp.raise_for_status()
textures_list: list[dict[{"name": str, "texture": str | None}]] = resp.json()
for item in textures_list:
id = ResourceLocation("minecraft", item["name"])
# items are first in the list (i think), so prioritize them
if id not in self.minecraft_textures:
self.minecraft_textures[id] = item["texture"]
return self
@dataclass(config=DEFAULT_CONFIG, frozen=True, repr=False, kw_only=True)
class RenderedItemStack(ItemStack):
name: LocalizedStr | None = None
texture: str | None = None
@model_validator(mode="after")
def _add_name_and_texture(self, info: ValidationInfo):
"""Loads the recipe from json if the actual value is a resource location str."""
if not info.context:
return self
context = cast_or_raise(info.context, MinecraftAssetsContext)
object.__setattr__(self, "name", context.i18n.localize_item(self))
object.__setattr__(self, "texture", self._get_texture(context))
return self
def _get_texture(self, context: MinecraftAssetsContext):
if self.namespace == "minecraft":
return context.minecraft_textures[self.id]
else:
# check if there's an override to apply
id = context.props.textures.override.get(self.id, self.id)
try:
return resolve_texture_from_metadata(
context.loader.mod_metadata,
id.with_path(f"textures/item/{id.path}.png"),
id.with_path(f"textures/block/{id.path}.png"),
)
except KeyError as e:
for missing_id in context.props.textures.missing:
if id.match(missing_id):
logging.getLogger(__name__).warn(f"No texture for {id}")
return MISSING_TEXTURE
raise KeyError(f"No texture for {id}") from e

View file

@ -20,7 +20,7 @@ from hexdoc.utils.resource_loader import LoaderContext
@total_ordering
class LocalizedStr(HexdocModel):
class LocalizedStr(HexdocModel, frozen=True):
"""Represents a string which has been localized."""
key: str
@ -83,7 +83,7 @@ class LocalizedStr(HexdocModel):
return self.value < other
class LocalizedItem(LocalizedStr):
class LocalizedItem(LocalizedStr, frozen=True):
@classmethod
def _localize(cls, i18n: I18n, key: str) -> Self:
return i18n.localize_item(key)
@ -253,6 +253,10 @@ class I18n(HexdocModel):
def localize_key(self, key: str) -> LocalizedStr:
return self.localize(f"key.{key}")
def localize_tag(self, tag: ResourceLocation):
localized = self.localize(f"tag.{tag.namespace}.{tag.path}")
return LocalizedStr(key=localized.key, value=f"Tag: {localized.value}")
class I18nContext(LoaderContext):
i18n: I18n

View file

@ -10,16 +10,12 @@ __all__ = [
"ItemResult",
]
from .abstract_recipes import Recipe
from .abstract_recipes import CraftingRecipe, Recipe
from .ingredients import (
ItemIngredient,
ItemIngredientOrList,
ItemResult,
MinecraftItemIdIngredient,
MinecraftItemTagIngredient,
)
from .recipes import (
CraftingRecipe,
CraftingShapedRecipe,
CraftingShapelessRecipe,
ItemResult,
)
from .recipes import CraftingShapedRecipe, CraftingShapelessRecipe

View file

@ -1,12 +1,15 @@
import logging
from typing import Any
from typing import Any, Self
from pydantic import ValidationInfo, model_validator
from pydantic.functional_validators import ModelWrapValidatorHandler
from hexdoc.utils import ResourceLocation, TypeTaggedUnion
from hexdoc.utils.deserialize import cast_or_raise
from hexdoc.utils.resource_loader import LoaderContext
from .ingredients import ItemResult
class Recipe(TypeTaggedUnion, type=None):
id: ResourceLocation
@ -14,23 +17,34 @@ class Recipe(TypeTaggedUnion, type=None):
group: str | None = None
category: str | None = None
@model_validator(mode="before")
def _pre_root(cls, values: Any, info: ValidationInfo):
# use wrap validator so we load the file *before* resolving the tagged union
@model_validator(mode="wrap")
@classmethod
def _pre_root(
cls,
value: Any,
handler: ModelWrapValidatorHandler[Self],
info: ValidationInfo,
) -> Self:
"""Loads the recipe from json if the actual value is a resource location str."""
if not info.context:
return values
return handler(value)
context = cast_or_raise(info.context, LoaderContext)
# if necessary, convert the id to a ResourceLocation
match values:
match value:
case str():
id = ResourceLocation.from_str(values)
id = ResourceLocation.from_str(value)
case ResourceLocation():
id = values
id = value
case _:
return values
return handler(value)
# load the recipe
_, data = context.loader.load_resource("data", "recipes", id)
logging.getLogger(__name__).debug(f"Load {cls} from {id}")
return data | {"id": id}
return handler(data | {"id": id})
class CraftingRecipe(Recipe, type=None):
result: ItemResult

View file

@ -1,4 +1,10 @@
from hexdoc.utils import NoValue, ResourceLocation, TypeTaggedUnion
from pydantic import ValidationInfo, model_validator
from hexdoc.minecraft import RenderedItemStack
from hexdoc.minecraft.assets import TAG_TEXTURE
from hexdoc.minecraft.i18n import I18nContext
from hexdoc.utils import HexdocModel, NoValue, ResourceLocation, TypeTaggedUnion
from hexdoc.utils.deserialize import cast_or_raise
class ItemIngredient(TypeTaggedUnion, type=None):
@ -9,8 +15,29 @@ ItemIngredientOrList = ItemIngredient | list[ItemIngredient]
class MinecraftItemIdIngredient(ItemIngredient, type=NoValue):
item: ResourceLocation
item: RenderedItemStack
class MinecraftItemTagIngredient(ItemIngredient, type=NoValue):
tag: ResourceLocation
item: RenderedItemStack | None = None
@model_validator(mode="after")
def _add_item(self, info: ValidationInfo):
if not info.context:
return self
context = cast_or_raise(info.context, I18nContext)
self.item = RenderedItemStack(
namespace=self.tag.namespace,
path=self.tag.path,
name=context.i18n.localize_tag(self.tag),
texture=TAG_TEXTURE,
)
return self
class ItemResult(HexdocModel):
item: RenderedItemStack
count: int = 1

View file

@ -1,24 +1,34 @@
from typing import Iterator
from pydantic import field_validator
from hexdoc.utils import HexdocModel
from hexdoc.utils.compat import HexVersion
from ..i18n import LocalizedItem
from .abstract_recipes import Recipe
from .abstract_recipes import CraftingRecipe
from .ingredients import ItemIngredientOrList
class ItemResult(HexdocModel):
item: LocalizedItem
count: int | None = None
class CraftingShapelessRecipe(CraftingRecipe, type="minecraft:crafting_shapeless"):
ingredients: list[ItemIngredientOrList]
class CraftingShapedRecipe(Recipe, type="minecraft:crafting_shaped"):
class CraftingShapedRecipe(CraftingRecipe, type="minecraft:crafting_shaped"):
key: dict[str, ItemIngredientOrList]
pattern: list[str]
result: ItemResult
show_notification: bool | None = None
@property
def ingredients(self) -> Iterator[ItemIngredientOrList | None]:
for row in self.pattern:
if len(row) > 3:
raise ValueError(f"Expected len(row) <= 3, got {len(row)}: `{row}`")
for item_key in row.ljust(3):
match item_key:
case " ":
yield None
case _:
yield self.key[item_key]
@field_validator("show_notification")
@classmethod
def _check_show_notification(cls, value: bool | None):
@ -28,11 +38,3 @@ class CraftingShapedRecipe(Recipe, type="minecraft:crafting_shaped"):
case HexVersion.v0_10_x | HexVersion.v0_9_x:
HexVersion.check(value is None, "show_notification")
return value
class CraftingShapelessRecipe(Recipe, type="minecraft:crafting_shapeless"):
ingredients: list[ItemIngredientOrList]
result: ItemResult
CraftingRecipe = CraftingShapedRecipe | CraftingShapelessRecipe

View file

@ -3,13 +3,14 @@ from typing import Self
from pydantic import Field, model_validator
from hexdoc.minecraft import Tag
from hexdoc.minecraft.assets import MinecraftAssetsContext
from hexdoc.plugin.manager import PluginManagerContext
from hexdoc.utils import ResourceLocation
from .text.formatting import FormattingContext
class BookContext(FormattingContext, PluginManagerContext):
class BookContext(FormattingContext, PluginManagerContext, MinecraftAssetsContext):
spoilered_advancements: set[ResourceLocation] = Field(default_factory=set)
@model_validator(mode="after")

View file

@ -5,7 +5,7 @@ _T = TypeVar("_T")
_P = ParamSpec("_P")
def must_yield(f: Callable[_P, Iterator[_T]]) -> Callable[_P, Iterator[_T]]:
def must_yield_something(f: Callable[_P, Iterator[_T]]) -> Callable[_P, Iterator[_T]]:
"""Raises StopIteration if the wrapped iterator doesn't yield anything."""
@functools.wraps(f)

View file

@ -5,6 +5,7 @@ from jinja2.ext import Extension
from jinja2.parser import Parser
from jinja2.runtime import Context
from markupsafe import Markup
from pydantic import ConfigDict, validate_call
from hexdoc.minecraft import I18n, LocalizedStr
from hexdoc.patchouli import Book, FormatTree
@ -12,7 +13,7 @@ from hexdoc.patchouli.book import Book
from hexdoc.patchouli.text import HTMLStream
from hexdoc.patchouli.text.formatting import FormatTree
from hexdoc.utils.resource import ResourceLocation
from hexdoc.utils.resource_loader import HexdocMetadata
from hexdoc.utils.resource_loader import HexdocMetadata, resolve_texture_from_metadata
from . import Properties
from .deserialize import cast_or_raise
@ -101,13 +102,11 @@ def hexdoc_localize(
@pass_context
def hexdoc_texture_url(context: Context, id: ResourceLocation) -> str:
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def hexdoc_texture(context: Context, id: ResourceLocation) -> str:
try:
metadata = cast_or_raise(
context["mod_metadata"],
dict[str, HexdocMetadata],
)[id.namespace]
return f"{metadata.asset_url}/{metadata.textures[id].as_posix()}"
mod_metadata = cast_or_raise(context["mod_metadata"], dict[str, HexdocMetadata])
return resolve_texture_from_metadata(mod_metadata, id)
except Exception as e:
e.add_note(f"id:\n {id}")
raise

View file

@ -87,6 +87,16 @@ class TemplateProps(StripHiddenModel):
return values
class MinecraftAssetsProps(StripHiddenModel):
ref: str
version: str
class TexturesProps(StripHiddenModel):
missing: list[ResourceLocation]
override: dict[ResourceLocation, ResourceLocation]
class Properties(StripHiddenModel):
env: EnvironmentVariableProps
@ -104,6 +114,11 @@ class Properties(StripHiddenModel):
entry_id_blacklist: set[ResourceLocation] = Field(default_factory=set)
minecraft_assets: MinecraftAssetsProps
# FIXME: remove this and get the data from the actual model files
textures: TexturesProps
template: TemplateProps
@classmethod

View file

@ -90,6 +90,10 @@ class BaseResourceLocation:
def _ser_model(self) -> str:
return str(self)
@property
def id(self) -> ResourceLocation:
return ResourceLocation(self.namespace, self.path)
def __repr__(self) -> str:
return f"{self.namespace}:{self.path}"
@ -175,6 +179,9 @@ class ItemStack(BaseResourceLocation, regex=_make_regex(count=True, nbt=True)):
count: int | None = None
nbt: str | None = None
def __init_subclass__(cls, **kwargs: Any):
super().__init_subclass__(regex=cls._from_str_regex, **kwargs)
def i18n_key(self, root: str = "item") -> str:
# TODO: is this how i18n works????? (apparently, because it's working)
return f"{root}.{self.namespace}.{self.path.replace('/', '.')}"

View file

@ -15,7 +15,7 @@ from pydantic.dataclasses import dataclass
from hexdoc.plugin.manager import PluginManager
from hexdoc.utils.deserialize import JSONDict, decode_json_dict
from hexdoc.utils.iterators import must_yield
from hexdoc.utils.iterators import must_yield_something
from hexdoc.utils.model import DEFAULT_CONFIG, HexdocModel, ValidationContext
from hexdoc.utils.path import strip_suffixes, write_to_path
@ -47,12 +47,6 @@ class HexdocMetadata(HexdocModel):
def path(cls, modid: str) -> Path:
return Path(f"{modid}.hexdoc.json")
def export(self, loader: ModResourceLoader):
loader.export(
self.path(loader.props.modid),
self.model_dump_json(by_alias=True, warnings=False),
)
@dataclass(config=DEFAULT_CONFIG, kw_only=True)
class ModResourceLoader:
@ -114,21 +108,23 @@ class ModResourceLoader:
for path_resource_dir in stack.enter_context(resource_dir.load(pm))
],
)
loader.mod_metadata |= loader.load_metadata(model_type=HexdocMetadata)
# export this mod's metadata
loader.mod_metadata[props.modid] = metadata = HexdocMetadata(
metadata = HexdocMetadata(
book_url=f"{props.url}/v/{version}",
asset_url=props.env.githubusercontent,
textures=loader._map_own_assets("textures", root=repo_root),
sounds=loader._map_own_assets("sounds", root=repo_root),
)
metadata.export(loader)
loader.mod_metadata[props.modid] = metadata
loader.export(
metadata.path(loader.props.modid),
metadata.model_dump_json(by_alias=True, warnings=False),
)
yield loader
def __post_init__(self):
self.mod_metadata |= self.load_metadata(model_type=HexdocMetadata)
def _map_own_assets(self, folder: str, *, root: str | Path):
return {
id: path.resolve().relative_to(root)
@ -170,7 +166,7 @@ class ModResourceLoader:
return metadata
# TODO: maybe this should take lang as a variable?
@must_yield
@must_yield_something
def load_book_assets(
self,
book_id: ResourceLocation,
@ -494,3 +490,17 @@ class LoaderContext(ValidationContext):
@property
def props(self):
return self.loader.props
def resolve_texture_from_metadata(
mod_metadata: dict[str, HexdocMetadata],
id: ResourceLocation,
*fallbacks: ResourceLocation,
) -> str:
try:
metadata = mod_metadata[id.namespace]
return f"{metadata.asset_url}/{metadata.textures[id].as_posix()}"
except KeyError:
if not fallbacks:
raise
return resolve_texture_from_metadata(mod_metadata, fallbacks[0], *fallbacks[1:])

View file

@ -33,15 +33,16 @@ classifiers = [
]
requires-python = ">=3.11"
dependencies = [
"typing_extensions>=4.6.1,<5",
"importlib_resources>=6.0.1,<7",
"more_itertools>=10.1.0,<11",
"pydantic>=2.3.0,<3,!=2.4.0",
"pydantic_settings>=2.0.3,<3",
"Jinja2>=3.1.2,<4",
"pyjson5>=1.6.3,<2",
"pluggy>=1.3.0,<2",
"typer[all]>=0.9.0,<1",
"typing_extensions~=4.6",
"importlib_resources~=6.0",
"more_itertools~=10.1",
"pydantic~=2.3,!=2.4.0",
"pydantic_settings~=2.0",
"Jinja2~=3.1",
"pyjson5~=1.6",
"pluggy~=1.3",
"typer[all]~=0.9.0",
"requests~=2.31",
]
dynamic = ["version"]