Add animated textures, refactor a lot of things

This commit is contained in:
object-Object 2023-10-08 13:11:50 -04:00
parent 8200465cdf
commit dab57062b4
29 changed files with 519 additions and 232 deletions

View file

@ -18,5 +18,6 @@
"files.associations": {
"*.js.jinja": "javascript",
"*.css.jinja": "css",
"*.jcss.jinja": "jinja-css", // for files with a lot of jinja stuff, where the linting isn't useful
},
}

View file

@ -56,6 +56,42 @@ missing = [
"hexcasting:spellbook" = "hexcasting:spellbook_empty"
"hexcasting:staff/quenched" = "hexcasting:staff/quenched_0"
# ugh. TODO: properly get from model files
"hexcasting:dye_colorizer_black" = "hexcasting:colorizer/dye_black"
"hexcasting:dye_colorizer_blue" = "hexcasting:colorizer/dye_blue"
"hexcasting:dye_colorizer_brown" = "hexcasting:colorizer/dye_brown"
"hexcasting:dye_colorizer_cyan" = "hexcasting:colorizer/dye_cyan"
"hexcasting:dye_colorizer_gray" = "hexcasting:colorizer/dye_gray"
"hexcasting:dye_colorizer_green" = "hexcasting:colorizer/dye_green"
"hexcasting:dye_colorizer_light_blue" = "hexcasting:colorizer/dye_light_blue"
"hexcasting:dye_colorizer_light_gray" = "hexcasting:colorizer/dye_light_gray"
"hexcasting:dye_colorizer_lime" = "hexcasting:colorizer/dye_lime"
"hexcasting:dye_colorizer_magenta" = "hexcasting:colorizer/dye_magenta"
"hexcasting:dye_colorizer_orange" = "hexcasting:colorizer/dye_orange"
"hexcasting:dye_colorizer_pink" = "hexcasting:colorizer/dye_pink"
"hexcasting:dye_colorizer_purple" = "hexcasting:colorizer/dye_purple"
"hexcasting:dye_colorizer_red" = "hexcasting:colorizer/dye_red"
"hexcasting:dye_colorizer_white" = "hexcasting:colorizer/dye_white"
"hexcasting:dye_colorizer_yellow" = "hexcasting:colorizer/dye_yellow"
"hexcasting:pride_colorizer_agender" = "hexcasting:colorizer/pride_agender"
"hexcasting:pride_colorizer_aroace" = "hexcasting:colorizer/pride_aroace"
"hexcasting:pride_colorizer_aromantic" = "hexcasting:colorizer/pride_aromantic"
"hexcasting:pride_colorizer_asexual" = "hexcasting:colorizer/pride_asexual"
"hexcasting:pride_colorizer_bisexual" = "hexcasting:colorizer/pride_bisexual"
"hexcasting:pride_colorizer_demiboy" = "hexcasting:colorizer/pride_demiboy"
"hexcasting:pride_colorizer_demigirl" = "hexcasting:colorizer/pride_demigirl"
"hexcasting:pride_colorizer_gay" = "hexcasting:colorizer/pride_gay"
"hexcasting:pride_colorizer_genderfluid" = "hexcasting:colorizer/pride_genderfluid"
"hexcasting:pride_colorizer_genderqueer" = "hexcasting:colorizer/pride_genderqueer"
"hexcasting:pride_colorizer_intersex" = "hexcasting:colorizer/pride_intersex"
"hexcasting:pride_colorizer_lesbian" = "hexcasting:colorizer/pride_lesbian"
"hexcasting:pride_colorizer_nonbinary" = "hexcasting:colorizer/pride_nonbinary"
"hexcasting:pride_colorizer_pansexual" = "hexcasting:colorizer/pride_pansexual"
"hexcasting:pride_colorizer_plural" = "hexcasting:colorizer/pride_plural"
"hexcasting:pride_colorizer_transgender" = "hexcasting:colorizer/pride_transgender"
"hexcasting:uuid_colorizer" = "hexcasting:colorizer/uuid"
"hexcasting:default_colorizer" = "hexcasting:colorizer/uuid"
[template]
static_dir = "static"
packages = ["hexdoc"]

View file

@ -0,0 +1,13 @@
{% for animation in animations %}
.{{ animation.class_name }} {
animation: {{ animation.class_name }} {{ animation.time_seconds }}s linear infinite;
background-size: 32px;
background-image: url("{{ animation.url }}");
}
@keyframes {{ animation.class_name }} {
{% for frame in animation.frames %}
{{ frame.start_percent }}%, {{ frame.end_percent }}% { background-position-y: {{ frame.index * -32 }}px }
{% endfor %}
}
{% endfor %}

View file

@ -12,12 +12,25 @@
{# 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>
{% if item.texture.meta %}
<div
role="img"
title="{{ item.name }}"
aria-label="Animated image of {{ item.name }}"
class="animated-texture {{ item.texture.class_name }}"
></div>
{% else %}
<img
title="{{ item.name }}"
alt="Image of {{ item.name }}"
src="{{ item.texture.url }}"
loading="lazy"
>
{% endif %}
{% if always_show_count or count > 1 %}
<div class="badge">{{ count }}</div>
{% endif %}
{%- endmacro %}
{% macro render_ingredient(ingredient) -%}

View file

@ -97,16 +97,12 @@ blockquote.crafting-info {
right: -4px;
}
:is(.crafting-table-grid, .crafting-table-result) :is(img, span) {
:is(.crafting-table-grid, .crafting-table-result) :is(img, div) {
image-rendering: pixelated;
width: 32px;
height: 32px;
}
.tooltip {
white-space: nowrap;
}
a.permalink {
margin-left: 0.5em;
}
@ -267,3 +263,8 @@ canvas.spell-viz {
--dark-mode: 1;
}
}
/*{{"*"}}/
{# ^ so this part doesn't show linter errors in vscode (scuffed, i know) #}
{% include "components/textures.jcss.jinja" %}
/**/

View file

@ -296,6 +296,19 @@ function hookSpoiler(elem) {
}
}
let startTime = null;
function hookSyncAnimations(elem) {
elem.addEventListener("animationstart", (e) => {
const anim = e.target.getAnimations()[0];
if (startTime == null) {
startTime = anim.startTime;
} else {
anim.startTime = startTime;
}
})
}
// these are filled by Jinja
const BOOK_URL = "{{ props.url }}";
@ -406,6 +419,7 @@ document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll(".details-collapsible").forEach(hookLoad);
document.querySelectorAll("a.toggle-link").forEach(hookToggle);
document.querySelectorAll(".spoilered").forEach(hookSpoiler);
document.querySelectorAll(".animated-texture").forEach(hookSyncAnimations)
function tick(prevTime, time) {
const dt = time - prevTime;

View file

@ -9,11 +9,13 @@ from typing import Annotated, Union
import typer
from hexdoc.minecraft import I18n
from hexdoc.minecraft.assets.textures import AnimatedTexture, Texture
from hexdoc.utils import ModResourceLoader
from hexdoc.utils.resource import ResourceLocation
from .load import load_book, load_books, load_common_data
from .render import create_jinja_env, render_book
from .sitemap import (
from .utils.load import load_book, load_books, load_common_data
from .utils.render import create_jinja_env, render_book
from .utils.sitemap import (
assert_version_exists,
delete_root_book,
delete_updated_books,
@ -71,7 +73,16 @@ def render(
# load data
props, pm, version = load_common_data(props_file, verbosity)
books, mod_metadata = load_books(props, pm, lang, allow_missing)
books, all_metadata = load_books(props, pm, lang, allow_missing)
textures = dict[ResourceLocation, Texture]()
animations = list[AnimatedTexture]()
for metadata in all_metadata.values():
textures |= metadata.textures
for texture in metadata.textures.values():
if isinstance(texture, AnimatedTexture):
animations.append(texture)
logger = logging.getLogger(__name__)
logger.info(f"update_latest={update_latest}, release={release}")
@ -102,7 +113,9 @@ def render(
i18n=i18n,
templates=templates,
output_dir=output_dir,
mod_metadata=mod_metadata,
all_metadata=all_metadata,
textures=textures,
animations=animations,
allow_missing=allow_missing,
version=version_,
is_root=is_root,

View file

@ -1,11 +1,14 @@
import logging
import subprocess
from pathlib import Path
from hexdoc.hexcasting.hex_book import load_hex_book
from hexdoc.minecraft import I18n
from hexdoc.minecraft.assets.textures import Texture
from hexdoc.patchouli import Book
from hexdoc.plugin import PluginManager
from hexdoc.utils import ModResourceLoader, Properties
from hexdoc.utils.metadata import HexdocMetadata
from .logging import setup_logging
@ -26,6 +29,33 @@ def load_version(props: Properties, pm: PluginManager):
return version
def load_all_metadata(props: Properties, pm: PluginManager, loader: ModResourceLoader):
version = pm.mod_version(props.modid)
root = Path(
subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
encoding="utf-8",
).stdout.strip()
)
# this mod's metadata
metadata = HexdocMetadata(
book_url=f"{props.url}/v/{version}",
asset_url=props.env.githubusercontent,
textures={
texture.file_id: texture for texture in Texture.load_all(root, loader)
},
)
loader.export(
metadata.path(props.modid),
metadata.model_dump_json(by_alias=True, warnings=False),
)
return loader.load_metadata(model_type=HexdocMetadata) | {props.modid: metadata}
def load_book(
props: Properties,
pm: PluginManager,
@ -37,10 +67,11 @@ def load_book(
lang = props.default_lang
with ModResourceLoader.clean_and_load_all(props, pm) as loader:
all_metadata = load_all_metadata(props, pm, loader)
lang, i18n = _load_i18n(loader, lang, allow_missing)[0]
_, data = Book.load_book_json(loader, props.book)
book = load_hex_book(data, pm, loader, i18n)
book = load_hex_book(data, pm, loader, i18n, all_metadata)
return lang, book, i18n
@ -51,17 +82,20 @@ def load_books(
lang: str | None,
allow_missing: bool,
):
"""books, mod_metadata"""
"""books, all_metadata"""
with ModResourceLoader.clean_and_load_all(props, pm) as loader:
_, book_data = Book.load_book_json(loader, props.book)
all_metadata = load_all_metadata(props, pm, loader)
_, book_data = Book.load_book_json(loader, props.book)
books = dict[str, tuple[Book, I18n]]()
for lang, i18n in _load_i18n(loader, lang, allow_missing):
books[lang] = (load_hex_book(book_data, pm, loader, i18n), i18n)
book = load_hex_book(book_data, pm, loader, i18n, all_metadata)
books[lang] = (book, i18n)
loader.export_dir = None # only export the first (default) book
return books, loader.mod_metadata
return books, all_metadata
def _load_i18n(

View file

@ -15,6 +15,7 @@ from jinja2 import (
from jinja2.sandbox import SandboxedEnvironment
from hexdoc.minecraft import I18n
from hexdoc.minecraft.assets.textures import AnimatedTexture, Texture
from hexdoc.patchouli import Book
from hexdoc.utils import Properties
from hexdoc.utils.jinja_extensions import (
@ -24,8 +25,9 @@ from hexdoc.utils.jinja_extensions import (
hexdoc_texture,
hexdoc_wrap,
)
from hexdoc.utils.metadata import HexdocMetadata
from hexdoc.utils.path import write_to_path
from hexdoc.utils.resource_loader import HexdocMetadata
from hexdoc.utils.resource import ResourceLocation
from .sitemap import MARKER_NAME, SitemapMarker
@ -64,7 +66,9 @@ def render_book(
i18n: I18n,
templates: dict[str, Template],
output_dir: Path,
mod_metadata: dict[str, HexdocMetadata],
all_metadata: dict[str, HexdocMetadata],
textures: dict[ResourceLocation, Texture],
animations: list[AnimatedTexture],
allow_missing: bool,
version: str,
is_root: bool,
@ -89,10 +93,13 @@ def render_book(
**props.template.args,
"book": book,
"props": props,
"i18n": i18n,
"page_url": page_url,
"version": version,
"lang": lang,
"mod_metadata": mod_metadata,
"all_metadata": all_metadata,
"textures": textures,
"animations": animations,
"is_bleeding_edge": version == "latest",
"_": lambda key: hexdoc_localize( # i18n helper
key,

View file

@ -10,6 +10,7 @@ from hexdoc.plugin import PluginManager
from hexdoc.utils import HexdocModel, ModResourceLoader, ResourceLocation, init_context
from hexdoc.utils.compat import HexVersion
from hexdoc.utils.deserialize import cast_or_raise
from hexdoc.utils.metadata import HexdocMetadata
from hexdoc.utils.properties import PatternStubProps
from .pattern import Direction, PatternInfo
@ -20,6 +21,7 @@ def load_hex_book(
pm: PluginManager,
loader: ModResourceLoader,
i18n: I18n,
all_metadata: dict[str, HexdocMetadata],
):
with init_context(data):
context = HexContext(
@ -28,6 +30,7 @@ def load_hex_book(
i18n=i18n,
# this SHOULD be set (as a ResourceLocation) by Book.get_book_json
book_id=cast_or_raise(data["id"], ResourceLocation),
all_metadata=all_metadata,
)
return Book.model_validate(data, context=context)

View file

@ -4,10 +4,8 @@ __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

@ -1,13 +1,7 @@
__all__ = [
"MinecraftAssetsContext",
"RenderedItemStack",
"TextureContext",
"TAG_TEXTURE",
"MISSING_TEXTURE",
]
from .assets import (
MISSING_TEXTURE,
TAG_TEXTURE,
MinecraftAssetsContext,
RenderedItemStack,
)
from .textures import MISSING_TEXTURE, TAG_TEXTURE, TextureContext

View file

@ -1,82 +0,0 @@
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

@ -0,0 +1,213 @@
from __future__ import annotations
import logging
from functools import cached_property
from pathlib import Path
from typing import Literal, Self
from pydantic import Field, model_validator
from hexdoc.minecraft.i18n import I18nContext, LocalizedStr
from hexdoc.utils import HexdocModel, ResourceLocation
from hexdoc.utils.external import fetch_minecraft_textures
from hexdoc.utils.properties import Properties
from hexdoc.utils.resource_loader import ModResourceLoader
from hexdoc.utils.resource_model import InlineModel
# 16x16 hashtag icon for tags
TAG_TEXTURE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAC4jAAAuIwF4pT92AAAANUlEQVQ4y2NgGJRAXV39v7q6+n9cfGTARKllFBvAiOxMUjTevHmTkSouGPhAHA0DWnmBrgAANLIZgSXEQxIAAAAASUVORK5CYII="
# purple and black square
MISSING_TEXTURE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAC4jAAAuIwF4pT92AAAAJElEQVQoz2NkwAF+MPzAKs7EQCIY1UAMYMQV3hwMHKOhRD8NAPogBA/DVsDEAAAAAElFTkSuQmCC"
class Texture(InlineModel):
file_id: ResourceLocation
url: str | None
meta: None = None
@classmethod
def load_all(cls, root: Path, loader: ModResourceLoader):
for _, id, path in loader.find_resources(
"assets",
namespace=loader.props.modid,
folder="textures",
glob=f"**/*.png",
):
texture = "/" + path.resolve().relative_to(root).as_posix()
meta_path = path.with_suffix(".png.mcmeta")
if meta_path.is_file():
yield AnimatedTexture(
file_id=id,
url=texture,
meta=AnimationMeta.model_validate_json(meta_path.read_bytes()),
)
else:
yield Texture(file_id=id, url=texture)
@classmethod
def load_id(cls, id: ResourceLocation, context: TextureContext):
"""Implements InlineModel."""
return cls.find(id, props=context.props, textures=context.textures)
@classmethod
def find(
cls,
*ids: ResourceLocation,
props: Properties,
textures: dict[ResourceLocation, Texture],
):
for id in ids:
id = id.with_path(id.path.removeprefix("textures/"))
if id in textures:
return textures[id]
# fallback/error
message = f"No texture for {', '.join(str(i) for i in ids)}"
for missing_id in props.textures.missing:
for id in ids:
if id.match(missing_id):
logging.getLogger(__name__).warn(message)
return Texture(file_id=id, url=MISSING_TEXTURE)
raise KeyError(message)
class AnimatedTexture(Texture):
meta: AnimationMeta
@property
def class_name(self):
return self.file_id.class_name
@property
def time_seconds(self):
return self.time / 20
@cached_property
def time(self):
return sum(time for _, time in self._normalized_frames)
@property
def frames(self):
start = 0
for index, time in self._normalized_frames:
yield AnimatedTextureFrame(
index=index,
start=start,
time=time,
animation_time=self.time,
)
start += time
@property
def _normalized_frames(self):
"""index, time"""
animation = self.meta.animation
for i, frame in enumerate(animation.frames):
match frame:
case int(index):
time = None
case AnimationMetaFrame(index=index, time=time):
pass
if index is None:
index = i
if time is None:
time = animation.frametime
yield index, time
class AnimatedTextureFrame(HexdocModel):
index: int
start: int
time: int
animation_time: int
@property
def start_percent(self):
return self._format_time(self.start)
@property
def end_percent(self):
return self._format_time(self.start + self.time, backoff=True)
def _format_time(self, time: int, *, backoff: bool = False) -> str:
percent = 100 * time / self.animation_time
if backoff and percent < 100:
percent -= 0.0001
return f"{percent:.4f}".rstrip("0").rstrip(".")
class AnimationMeta(HexdocModel):
animation: AnimationMetaTag
class AnimationMetaTag(HexdocModel):
interpolate: Literal[False] # TODO: handle interpolation
width: None = None # TODO: handle non-square textures
height: None = None
frametime: int = 1
frames: list[int | AnimationMetaFrame]
class AnimationMetaFrame(HexdocModel):
index: int | None = None
time: int | None = None
class TextureContext(I18nContext):
textures: dict[ResourceLocation, Texture] = Field(default_factory=dict)
@model_validator(mode="after")
def _add_minecraft_textures(self) -> Self:
minecraft_textures = fetch_minecraft_textures(
ref=self.props.minecraft_assets.ref,
version=self.props.minecraft_assets.version,
)
for item in minecraft_textures:
id = ResourceLocation("minecraft", item["name"])
# prefer items, since they're added first
if id not in self.textures:
self.textures[id] = Texture(file_id=id, url=item["texture"])
return self
class WithTexture(InlineModel):
id: ResourceLocation
name: LocalizedStr
texture: Texture
class ItemWithTexture(WithTexture):
@classmethod
def load_id(cls, id: ResourceLocation, context: TextureContext):
"""Implements InlineModel."""
texture_id = context.props.textures.override.get(id, id)
return cls(
id=id,
name=context.i18n.localize_item(id),
texture=Texture.find(
texture_id,
texture_id.with_path(f"item/{texture_id.path}.png"),
texture_id.with_path(f"block/{texture_id.path}.png"),
props=context.props,
textures=context.textures,
),
)
class TagWithTexture(WithTexture):
@classmethod
def load_id(cls, id: ResourceLocation, context: TextureContext):
return cls(
id=id,
name=context.i18n.localize_item_tag(id),
texture=Texture(file_id=id, url=TAG_TEXTURE),
)

View file

@ -219,17 +219,22 @@ class I18n(HexdocModel):
f"hexcasting.{key_group}.{op_id}",
)
def localize_item(self, item: ItemStack | str) -> LocalizedItem:
def localize_item(self, item: str | ResourceLocation | ItemStack) -> LocalizedItem:
"""Localizes the given item resource name.
Raises KeyError if i18n is enabled and skip_errors is False but the key has no localization.
"""
if isinstance(item, str):
item = ItemStack.from_str(item)
match item:
case str():
item = ItemStack.from_str(item)
case ResourceLocation(namespace=namespace, path=path):
item = ItemStack(namespace=namespace, path=path)
case _:
pass
localized = self.localize(
item.i18n_key("block"),
item.i18n_key(),
item.i18n_key("block"),
)
return LocalizedItem(key=localized.key, value=localized.value)

View file

@ -4,7 +4,7 @@ from hexdoc.utils.resource_model import InlineIDModel
from .ingredients import ItemResult
class Recipe(InlineIDModel, TypeTaggedUnion, type=None):
class Recipe(TypeTaggedUnion, InlineIDModel, type=None):
group: str | None = None
category: str | None = None

View file

@ -1,10 +1,5 @@
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
from hexdoc.minecraft.assets.textures import ItemWithTexture, TagWithTexture
from hexdoc.utils import HexdocModel, NoValue, TypeTaggedUnion
class ItemIngredient(TypeTaggedUnion, type=None):
@ -15,29 +10,17 @@ ItemIngredientOrList = ItemIngredient | list[ItemIngredient]
class MinecraftItemIdIngredient(ItemIngredient, type=NoValue):
item: RenderedItemStack
item: ItemWithTexture
class MinecraftItemTagIngredient(ItemIngredient, type=NoValue):
tag: ResourceLocation
item: RenderedItemStack | None = None
tag: TagWithTexture
@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_item_tag(self.tag),
texture=TAG_TEXTURE,
)
return self
@property
def item(self):
return self.tag
class ItemResult(HexdocModel):
item: RenderedItemStack
item: ItemWithTexture
count: int = 1

View file

@ -133,9 +133,7 @@ class Book(HexdocModel):
# load categories
self._categories = Category.load_all(context, self.id, self.use_resource_pack)
for id, category in self._categories.items():
self._link_bases[(id, None)] = context.loader.get_link_base(
category.resource_dir
)
self._link_bases[(id, None)] = context.get_link_base(category.resource_dir)
if not self._categories:
raise ValueError(
@ -167,7 +165,7 @@ class Book(HexdocModel):
):
entry = Entry.load(resource_dir, id, data, context)
link_base = context.loader.get_link_base(resource_dir)
link_base = context.get_link_base(resource_dir)
self._link_bases[(id, None)] = link_base
for page in entry.pages:
if page.anchor is not None:

View file

@ -3,16 +3,27 @@ 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 hexdoc.utils.metadata import MetadataContext
from hexdoc.utils.resource import PathResourceDir
from .text.formatting import FormattingContext
class BookContext(FormattingContext, PluginManagerContext, MinecraftAssetsContext):
class BookContext(
FormattingContext,
PluginManagerContext,
MetadataContext,
):
spoilered_advancements: set[ResourceLocation] = Field(default_factory=set)
def get_link_base(self, resource_dir: PathResourceDir) -> str:
modid = resource_dir.modid
if modid is None or modid == self.props.modid:
return ""
return self.all_metadata[modid].book_url
@model_validator(mode="after")
def _post_root_load_tags(self) -> Self:
self.spoilered_advancements |= Tag.load(

View file

@ -64,8 +64,12 @@ JSONDict = dict[str, "JSONValue"]
JSONValue = JSONDict | list["JSONValue"] | str | int | float | bool | None
def decode_json_dict(data: str) -> JSONDict:
decoded = pyjson5.decode(data)
def decode_json_dict(data: str | bytes) -> JSONDict:
match data:
case str():
decoded = pyjson5.decode(data)
case _:
decoded = pyjson5.decode_utf8(data)
assert isinstance_or_raise(decoded, dict)
return decoded

View file

@ -0,0 +1,26 @@
import logging
from typing import TypedDict
import requests
class MinecraftAssetsTextureContent(TypedDict):
name: str
texture: str | None
def fetch_minecraft_textures(
*,
ref: str,
version: str,
) -> list[MinecraftAssetsTextureContent]:
url = (
"https://raw.githubusercontent.com/PrismarineJS/minecraft-assets"
f"/{ref}/data/{version}/texture_content.json"
)
logging.getLogger(__name__).info(f"Fetch textures from {url}")
resp = requests.get(url)
resp.raise_for_status()
return resp.json()

View file

@ -15,3 +15,11 @@ def must_yield_something(f: Callable[_P, Iterator[_T]]) -> Callable[_P, Iterator
yield from iterator
return wrapper
def listify(f: Callable[_P, Iterator[_T]]) -> Callable[_P, list[_T]]:
@functools.wraps(f)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> list[_T]:
return list(f(*args, **kwargs))
return wrapper

View file

@ -8,12 +8,12 @@ from markupsafe import Markup
from pydantic import ConfigDict, validate_call
from hexdoc.minecraft import I18n, LocalizedStr
from hexdoc.minecraft.assets.textures import Texture
from hexdoc.patchouli import Book, FormatTree
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, resolve_texture_from_metadata
from . import Properties
from .deserialize import cast_or_raise
@ -105,8 +105,12 @@ def hexdoc_localize(
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def hexdoc_texture(context: Context, id: ResourceLocation) -> str:
try:
mod_metadata = cast_or_raise(context["mod_metadata"], dict[str, HexdocMetadata])
return resolve_texture_from_metadata(mod_metadata, id)
props = cast_or_raise(context["props"], Properties)
textures = cast_or_raise(context["textures"], dict[Any, Any])
texture = Texture.find(id, props=props, textures=textures)
assert texture.url is not None
return texture.url
except Exception as e:
e.add_note(f"id:\n {id}")
raise

View file

@ -0,0 +1,35 @@
from pathlib import Path
from typing import Self
from pydantic import model_validator
from hexdoc.minecraft.assets.textures import Texture, TextureContext
from .model import HexdocModel
from .properties import NoTrailingSlashHttpUrl
from .resource import ResourceLocation
class HexdocMetadata(HexdocModel):
"""Automatically generated at `export_dir/modid.hexdoc.json`."""
book_url: NoTrailingSlashHttpUrl
"""Github Pages base url."""
asset_url: NoTrailingSlashHttpUrl
"""raw.githubusercontent.com base url."""
textures: dict[ResourceLocation, Texture]
"""id -> path from repo root"""
@classmethod
def path(cls, modid: str) -> Path:
return Path(f"{modid}.hexdoc.json")
class MetadataContext(TextureContext):
all_metadata: dict[str, HexdocMetadata]
@model_validator(mode="after")
def _add_metadata_textures(self) -> Self:
for metadata in self.all_metadata.values():
self.textures |= metadata.textures
return self

View file

@ -111,6 +111,11 @@ class ResourceLocation(BaseResourceLocation, regex=_make_regex()):
def href(self) -> str:
return f"#{self.path}"
@property
def class_name(self):
stripped_path = re.sub(r"[\*\/\.]", "-", self.path)
return f"texture-{self.namespace}-{stripped_path}"
def with_namespace(self, namespace: str):
"""Returns a copy of this ResourceLocation with the given namespace."""
return ResourceLocation(namespace, self.path)

View file

@ -10,16 +10,15 @@ from pathlib import Path
from textwrap import dedent
from typing import Any, Callable, Literal, Self, TypeVar, overload
from pydantic import Field
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_something
from hexdoc.utils.model import DEFAULT_CONFIG, HexdocModel, ValidationContext
from hexdoc.utils.path import strip_suffixes, write_to_path
from .properties import NoTrailingSlashHttpUrl, Properties
from .deserialize import JSONDict, decode_json_dict
from .iterators import must_yield_something
from .model import DEFAULT_CONFIG, HexdocModel, ValidationContext
from .path import strip_suffixes, write_to_path
from .properties import Properties
from .resource import PathResourceDir, ResourceLocation, ResourceType
METADATA_SUFFIX = ".hexdoc.json"
@ -30,32 +29,12 @@ _T_Model = TypeVar("_T_Model", bound=HexdocModel)
ExportFn = Callable[[_T, _T | None], str]
class HexdocMetadata(HexdocModel):
"""Automatically generated at `export_dir/modid.hexdoc.json`."""
book_url: NoTrailingSlashHttpUrl
"""Github Pages base url."""
asset_url: NoTrailingSlashHttpUrl
"""raw.githubusercontent.com base url."""
textures: dict[ResourceLocation, Path]
"""id -> path from repo root"""
sounds: dict[ResourceLocation, Path]
"""id -> path from repo root"""
@classmethod
def path(cls, modid: str) -> Path:
return Path(f"{modid}.hexdoc.json")
@dataclass(config=DEFAULT_CONFIG, kw_only=True)
class ModResourceLoader:
props: Properties
export_dir: Path | None
resource_dirs: list[PathResourceDir]
mod_metadata: dict[str, HexdocMetadata] = Field(default_factory=dict)
@classmethod
def clean_and_load_all(
cls,
@ -90,13 +69,6 @@ class ModResourceLoader:
export: bool = True,
) -> Iterator[Self]:
export_dir = props.export_dir if export else None
version = pm.mod_version(props.modid)
repo_root = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
encoding="utf-8",
).stdout.strip()
with ExitStack() as stack:
loader = cls(
@ -108,20 +80,6 @@ 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
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),
)
loader.mod_metadata[props.modid] = metadata
loader.export(
metadata.path(loader.props.modid),
metadata.model_dump_json(by_alias=True, warnings=False),
)
yield loader
@ -136,12 +94,6 @@ class ModResourceLoader:
)
}
def get_link_base(self, resource_dir: PathResourceDir) -> str:
modid = resource_dir.modid
if modid is None or modid == self.props.modid:
return ""
return self.mod_metadata[modid].book_url
def load_metadata(
self,
*,
@ -490,17 +442,3 @@ 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

@ -34,14 +34,10 @@ class IDModel(HexdocModel):
@dataclass_transform()
class InlineIDModel(IDModel, ABC):
class InlineModel(HexdocModel, ABC):
@classmethod
@abstractmethod
def load_resource(
cls,
id: ResourceLocation,
loader: ModResourceLoader,
) -> tuple[PathResourceDir, JSONDict]:
def load_id(cls, id: ResourceLocation, context: Any) -> Self:
...
@model_validator(mode="wrap")
@ -65,7 +61,23 @@ class InlineIDModel(IDModel, ABC):
case _:
return handler(value)
# load the recipe
context = cast_or_raise(info.context, LoaderContext)
# load the data
context = cast_or_raise(info.context, ValidationContext)
return cls.load_id(id, context)
@dataclass_transform()
class InlineIDModel(IDModel, InlineModel, ABC):
@classmethod
def load_id(cls, id: ResourceLocation, context: LoaderContext):
resource_dir, data = cls.load_resource(id, context.loader)
return cls.load(resource_dir, id, data, context)
@classmethod
@abstractmethod
def load_resource(
cls,
id: ResourceLocation,
loader: ModResourceLoader,
) -> tuple[PathResourceDir, JSONDict]:
...