From 4f4563f4fef70a18e48bbf0c6d0a0c4feef2206a Mon Sep 17 00:00:00 2001 From: Drefvelin <54400154+Drefvelin@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:55:18 +0000 Subject: [PATCH 1/3] Add 3D bows and crossbows, animated textures, new skin targets, and an item-first upload flow - bow_3d / crossbow_3d: one texture plus a model per draw state (standby, pull 0-2, charged for crossbows), 60 KB (61440 bytes) for all files together at every rank. - Optional .mcmeta for every texture except armour layers; animated textures may be a grid of frames of the slot's size. Previews animate; review sheets and thumbnails use the first frame. - shared/skins/base_sets.json is the one list of skin targets for the backend and frontend. New: wands, mage blades, longswords, greataxes, battle standards (3D), pickaxes, shovels, fishing rods, instruments. Every non-armour target can also be 3D. - Armour single sets: leather, the six mage schools, and light, dark and wild mage. - The upload form now asks what you are skinning first (grouped, searchable tiles), then offers only the styles that item supports, with locked styles marked by rank, then one card per file. Co-Authored-By: Claude Opus 5.5 --- backend/render/src/scene.ts | 6 +- backend/src/api/skins_routes.py | 10 +- backend/src/characters/web_entitlements.py | 2 + backend/src/skins/animation.py | 196 +++ backend/src/skins/base_sets.py | 53 + backend/src/skins/display.py | 61 +- backend/src/skins/naming.py | 21 +- backend/src/skins/preview_3d.py | 55 +- backend/src/skins/review_sheet.py | 25 +- backend/src/skins/size_limits.py | 23 +- backend/src/skins/storage.py | 140 +- backend/src/skins/submissions.py | 63 +- .../src/skins/test_ranged_3d_and_animation.py | 409 ++++++ backend/src/skins/thumbnail.py | 5 +- backend/tests/test_skin_asset_lifecycle.py | 7 + docs/docs/cosmetics/naming.md | 12 +- docs/docs/cosmetics/skins.md | 12 +- docs/docs/integrations/armourshop.md | 3 + .../components/character/LoreItemEditor.tsx | 2 + .../app/components/profile/SkinShowcase.tsx | 83 +- frontend/app/components/skins/FileSlot.tsx | 111 ++ frontend/app/components/skins/KindHelp.tsx | 30 +- frontend/app/components/skins/KindPicker.tsx | 219 +-- .../app/components/skins/ModelPreview.tsx | 192 ++- .../app/components/skins/SkinTargetPicker.tsx | 158 ++ .../app/components/skins/UploadForm.test.tsx | 91 ++ frontend/app/components/skins/UploadForm.tsx | 1296 +++++++++-------- .../wiki/stationTextureAnimation.ts | 65 +- frontend/lib/skins/animatedTexture.ts | 59 + frontend/lib/skins/baseSets.test.ts | 54 +- frontend/lib/skins/baseSets.ts | 136 +- frontend/lib/skins/entitlementsDev.ts | 5 +- frontend/lib/skins/kindAccess.test.ts | 28 + frontend/lib/skins/kindAccess.ts | 50 + frontend/lib/skins/ownerFiles.test.ts | 50 +- frontend/lib/skins/ownerFiles.ts | 36 +- frontend/lib/skins/sizes.test.ts | 63 +- frontend/lib/skins/sizes.ts | 85 +- frontend/lib/textureAnimation.test.ts | 85 ++ frontend/lib/textureAnimation.ts | 221 +++ shared/skins/base_sets.json | 78 + 41 files changed, 3304 insertions(+), 996 deletions(-) create mode 100644 backend/src/skins/animation.py create mode 100644 backend/src/skins/base_sets.py create mode 100644 backend/src/skins/test_ranged_3d_and_animation.py create mode 100644 frontend/app/components/skins/FileSlot.tsx create mode 100644 frontend/app/components/skins/SkinTargetPicker.tsx create mode 100644 frontend/app/components/skins/UploadForm.test.tsx create mode 100644 frontend/lib/skins/animatedTexture.ts create mode 100644 frontend/lib/skins/kindAccess.test.ts create mode 100644 frontend/lib/skins/kindAccess.ts create mode 100644 frontend/lib/textureAnimation.test.ts create mode 100644 frontend/lib/textureAnimation.ts create mode 100644 shared/skins/base_sets.json diff --git a/backend/render/src/scene.ts b/backend/render/src/scene.ts index 7e0ddc43..5ea8f6b5 100644 --- a/backend/render/src/scene.ts +++ b/backend/render/src/scene.ts @@ -209,10 +209,12 @@ export async function renderPreviewJob( scene.add(worn); frameObject(camera, worn); out[view] = capture(renderer, scene, camera); - } else if (view === "model") { + } else if (view === "model" || view === "pull_2" || view === "charged") { + // 3D bows also show their drawn (and crossbows their loaded) model. + const modelKey = view !== "model" ? view : kind === "gun" ? "carry" : "model"; const { root } = await buildItemRoot(kind, assets, { center: true, - modelKey: kind === "gun" ? "carry" : "model", + modelKey, }); scene.add(root); const box = new THREE.Box3().setFromObject(root); diff --git a/backend/src/api/skins_routes.py b/backend/src/api/skins_routes.py index e1aa2021..ac253ca9 100644 --- a/backend/src/api/skins_routes.py +++ b/backend/src/api/skins_routes.py @@ -669,16 +669,17 @@ async def post_submissions( tiers_list = [tier] remapped: dict[str, bytes] = {} remapped_names: dict[str, str | None] = {} - for field in ARMOR_FIELDS: + legacy = ARMOR_FIELDS + tuple(f"{f}_mcmeta" for f in ARMOR_FIELDS) + for field in legacy: if field in files_bytes: tier_key = f"{tier}_{field}" remapped[tier_key] = files_bytes[field] remapped_names[tier_key] = filenames.get(field) files_bytes = { - k: v for k, v in files_bytes.items() if k not in ARMOR_FIELDS + k: v for k, v in files_bytes.items() if k not in legacy } filenames = { - k: v for k, v in filenames.items() if k not in ARMOR_FIELDS + k: v for k, v in filenames.items() if k not in legacy } files_bytes.update(remapped) filenames.update(remapped_names) @@ -820,7 +821,8 @@ def get_thumbnail( # The owner's uploads only; staff sheets and server files stay staff-side. -_OWNER_FILE_SUFFIXES = (".png", ".json") +# An animated texture has a sibling `.png.mcmeta`; it 404s when the PNG is still. +_OWNER_FILE_SUFFIXES = (".png", ".json", ".png.mcmeta") _OWNER_HIDDEN_FILES = frozenset({"meta.json", "review_sheet.png", "name_preview.png"}) diff --git a/backend/src/characters/web_entitlements.py b/backend/src/characters/web_entitlements.py index 5a4c0ba2..6ef792c2 100644 --- a/backend/src/characters/web_entitlements.py +++ b/backend/src/characters/web_entitlements.py @@ -17,6 +17,8 @@ "bow", "large_bow", "crossbow", + "bow_3d", + "crossbow_3d", "item_3d", "shield", "helmet_3d", diff --git a/backend/src/skins/animation.py b/backend/src/skins/animation.py new file mode 100644 index 00000000..84d900cc --- /dev/null +++ b/backend/src/skins/animation.py @@ -0,0 +1,196 @@ +"""Minecraft ``.png.mcmeta`` texture animation: validation and first-frame crops. + +Frames are laid out the way Minecraft reads them: frame size from the +animation's width/height (or a square of the shorter image side), numbered +left to right, then top to bottom. +""" + +from __future__ import annotations + +import io +import json +from pathlib import Path +from typing import Any + +from PIL import Image + +from .base_sets import mcmeta_max_bytes + +_ANIMATION_KEYS = frozenset({"frametime", "interpolate", "width", "height", "frames"}) + + +class AnimationError(ValueError): + """Invalid .mcmeta or a PNG that does not fit its frames.""" + + +def _positive_int(value: Any, *, minimum: int) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value >= minimum + + +def frame_size(animation: dict[str, Any], image: tuple[int, int]) -> tuple[int, int]: + img_w, img_h = image + w = animation.get("width") + h = animation.get("height") + if w is not None and h is not None: + return w, h + if w is not None: + return w, img_h + if h is not None: + return img_w, h + side = min(img_w, img_h) + return side, side + + +def parse_mcmeta( + data: bytes, + image: tuple[int, int], + expected_frame: tuple[int, int] | None = None, + *, + label: str = "texture", +) -> tuple[dict[str, Any], int]: + """Validate an .mcmeta for a PNG of ``image`` size; return (clean json, frame count).""" + cap = mcmeta_max_bytes() + if not data: + raise AnimationError(f"{label}: .mcmeta file is empty") + if len(data) > cap: + raise AnimationError( + f"{label}: .mcmeta is {len(data)} bytes (limit {cap} bytes)" + ) + try: + raw = json.loads(data.decode("utf-8-sig")) + except (UnicodeDecodeError, json.JSONDecodeError) as e: + raise AnimationError(f"{label}: .mcmeta is not valid JSON") from e + if not isinstance(raw, dict) or set(raw) != {"animation"}: + raise AnimationError( + f'{label}: .mcmeta must be an object with only an "animation" section' + ) + anim = raw["animation"] + if not isinstance(anim, dict): + raise AnimationError(f'{label}: .mcmeta "animation" must be an object') + unknown = sorted(set(anim) - _ANIMATION_KEYS) + if unknown: + raise AnimationError( + f"{label}: .mcmeta animation has unsupported keys: {', '.join(unknown)}" + ) + + clean: dict[str, Any] = {} + if "frametime" in anim: + if not _positive_int(anim["frametime"], minimum=1): + raise AnimationError(f"{label}: .mcmeta frametime must be a whole number ≥ 1") + clean["frametime"] = anim["frametime"] + if "interpolate" in anim: + if not isinstance(anim["interpolate"], bool): + raise AnimationError(f"{label}: .mcmeta interpolate must be true or false") + clean["interpolate"] = anim["interpolate"] + for key in ("width", "height"): + if key in anim: + if not _positive_int(anim[key], minimum=1): + raise AnimationError(f"{label}: .mcmeta {key} must be a whole number ≥ 1") + clean[key] = anim[key] + + img_w, img_h = image + fw, fh = frame_size(clean, image) + if expected_frame is not None and (fw, fh) != expected_frame: + ew, eh = expected_frame + raise AnimationError( + f"{label}: animation frames must be {ew}x{eh}, got {fw}x{fh}" + ) + if fw > img_w or fh > img_h or img_w % fw or img_h % fh: + raise AnimationError( + f"{label}: a {img_w}x{img_h} PNG is not a whole grid of {fw}x{fh} frames" + ) + count = (img_w // fw) * (img_h // fh) + + if "frames" in anim: + frames = anim["frames"] + if not isinstance(frames, list) or not frames: + raise AnimationError(f"{label}: .mcmeta frames must be a non-empty list") + out_frames: list[Any] = [] + for entry in frames: + if isinstance(entry, dict): + if set(entry) - {"index", "time"} or "index" not in entry: + raise AnimationError( + f"{label}: .mcmeta frame entries need an index (and optional time)" + ) + index = entry["index"] + if "time" in entry and not _positive_int(entry["time"], minimum=1): + raise AnimationError(f"{label}: .mcmeta frame time must be ≥ 1") + cleaned = {"index": index} + if "time" in entry: + cleaned["time"] = entry["time"] + else: + index = entry + cleaned = entry + if not _positive_int(index, minimum=0): + raise AnimationError(f"{label}: .mcmeta frame index must be a whole number ≥ 0") + if index >= count: + raise AnimationError( + f"{label}: .mcmeta frame {index} does not exist (PNG has {count} frames)" + ) + out_frames.append(cleaned) + clean["frames"] = out_frames + return {"animation": clean}, count + + +def mcmeta_bytes(clean: dict[str, Any]) -> bytes: + return json.dumps(clean, separators=(",", ":")).encode("utf-8") + + +def mcmeta_path(png: Path) -> Path: + return png.with_name(png.name + ".mcmeta") + + +def _read_animation(png: Path) -> tuple[dict[str, Any], tuple[int, int], int] | None: + """(animation, frame size, frame count) for a stored PNG with an .mcmeta, else None.""" + meta = mcmeta_path(png) + if not meta.is_file(): + return None + try: + anim = json.loads(meta.read_text(encoding="utf-8")).get("animation") or {} + with Image.open(png) as im: + size = im.size + except (OSError, ValueError, AttributeError): + return None + fw, fh = frame_size(anim, size) + if fw <= 0 or fh <= 0 or size[0] % fw or size[1] % fh: + return None + return anim, (fw, fh), (size[0] // fw) * (size[1] // fh) + + +def frame_count(png: Path) -> int: + """Animation frames of a stored PNG; 1 when it is not animated.""" + info = _read_animation(png) + return info[2] if info else 1 + + +def first_frame(png: Path, image: Image.Image) -> Image.Image: + """Crop ``image`` (loaded from ``png``) to the frame Minecraft shows first.""" + info = _read_animation(png) + if info is None: + return image + anim, (fw, fh), count = info + index = 0 + frames = anim.get("frames") + if isinstance(frames, list) and frames: + head = frames[0] + index = head.get("index", 0) if isinstance(head, dict) else head + if not isinstance(index, int) or not 0 <= index < count: + index = 0 + cols = image.width // fw + x, y = (index % cols) * fw, (index // cols) * fh + return image.crop((x, y, x + fw, y + fh)) + + +def first_frame_png(png: Path) -> bytes | None: + """First-frame PNG bytes for an animated stored PNG, else None.""" + if not mcmeta_path(png).is_file(): + return None + try: + with Image.open(png) as im: + im.load() + frame = first_frame(png, im.convert("RGBA")) + except OSError: + return None + buf = io.BytesIO() + frame.save(buf, format="PNG") + return buf.getvalue() diff --git a/backend/src/skins/base_sets.py b/backend/src/skins/base_sets.py new file mode 100644 index 00000000..6fdb9f07 --- /dev/null +++ b/backend/src/skins/base_sets.py @@ -0,0 +1,53 @@ +"""Load what player skins can apply to (ProvinceSystem/shared/skins/base_sets.json). + +The frontend reads the same file, so the upload picker and these checks agree. +""" + +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path +from typing import Any + +# backend/src/skins → ProvinceSystem/ +_PROVINCE_ROOT = Path(__file__).resolve().parents[3] +_BASE_SETS_PATH = _PROVINCE_ROOT / "shared" / "skins" / "base_sets.json" + + +@lru_cache(maxsize=1) +def load_base_sets() -> dict[str, Any]: + with _BASE_SETS_PATH.open(encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + raise RuntimeError(f"Invalid base sets at {_BASE_SETS_PATH}") + return data + + +def base_sets_by_kind() -> dict[str, frozenset[str]]: + """Upload kind → ArmourShop base set ids it may target (armour excluded).""" + out: dict[str, set[str]] = {} + for row in load_base_sets()["base_sets"]: + for kind in row["kinds"]: + out.setdefault(kind, set()).add(row["id"]) + return {kind: frozenset(ids) for kind, ids in out.items()} + + +def armor_single_sets() -> dict[str, str]: + """Single armour set tier id → label (``wild_mage`` → ``Wild Mage``).""" + return { + row["id"]: row["label"] for row in load_base_sets()["armor"]["single_sets"] + } + + +def limit(name: str) -> int: + return int(load_base_sets()["limits"][name]) + + +def ranged_3d_total_bytes() -> int: + """Every file of a bow_3d / crossbow_3d upload together, for every rank.""" + return limit("ranged_3d_total_bytes") + + +def mcmeta_max_bytes() -> int: + return limit("mcmeta_max_bytes") diff --git a/backend/src/skins/display.py b/backend/src/skins/display.py index 26141d3f..71ae9523 100644 --- a/backend/src/skins/display.py +++ b/backend/src/skins/display.py @@ -90,6 +90,57 @@ "fixed": {"scale": [2.5, 2.5, 2.5]}, } +# Vanilla item/bow.json and item/crossbow.json hand poses (autofill holes only). +_BOW_3D_DEFAULTS: dict[str, Any] = { + **copy.deepcopy(_DEFAULT_TAB), + "thirdperson_righthand": { + "rotation": [-80, 260, -40], + "translation": [-1, -2, 2.5], + "scale": [0.9, 0.9, 0.9], + }, + "thirdperson_lefthand": { + "rotation": [-80, -280, 40], + "translation": [-1, -2, 2.5], + "scale": [0.9, 0.9, 0.9], + }, + "firstperson_righthand": { + "rotation": [0, -90, 25], + "translation": [1.13, 3.2, 1.13], + "scale": [0.68, 0.68, 0.68], + }, + "firstperson_lefthand": { + "rotation": [0, 90, -25], + "translation": [1.13, 3.2, 1.13], + "scale": [0.68, 0.68, 0.68], + }, +} +_CROSSBOW_3D_DEFAULTS: dict[str, Any] = { + **copy.deepcopy(_DEFAULT_TAB), + "thirdperson_righthand": { + "rotation": [-90, 0, -60], + "translation": [2, 0.1, -3], + "scale": [0.9, 0.9, 0.9], + }, + "thirdperson_lefthand": { + "rotation": [-90, 0, 30], + "translation": [2, 0.1, -3], + "scale": [0.9, 0.9, 0.9], + }, + "firstperson_righthand": { + "rotation": [-90, 0, -55], + "translation": [1.13, 3.2, 1.13], + "scale": [0.68, 0.68, 0.68], + }, + "firstperson_lefthand": { + "rotation": [-90, 0, 35], + "translation": [1.13, 3.2, 1.13], + "scale": [0.68, 0.68, 0.68], + }, +} + +# Held 3D kinds: hand/ground/gui/fixed tabs, no head unless the player adds one. +_HELD_KINDS = ("item_3d", "gun", "bow_3d", "crossbow_3d") + class DisplayError(ValueError): """Invalid or incomplete model display after merge.""" @@ -182,7 +233,7 @@ def validate_java_block_model(model: dict[str, Any]) -> None: def required_tabs(kind: str) -> tuple[str, ...]: if kind in ("shield", "helmet_3d", "armor_helmet_3d", "mask"): return COMMON_TABS + (HEAD_TAB,) - if kind in ("item_3d", "gun"): + if kind in _HELD_KINDS: return COMMON_TABS raise DisplayError(f"Unknown 3D kind for display: {kind}") @@ -190,6 +241,10 @@ def required_tabs(kind: str) -> tuple[str, ...]: def defaults_for_kind(kind: str) -> dict[str, Any]: if kind == "shield": return copy.deepcopy(_SHIELD_DEFAULTS) + if kind == "bow_3d": + return copy.deepcopy(_BOW_3D_DEFAULTS) + if kind == "crossbow_3d": + return copy.deepcopy(_CROSSBOW_3D_DEFAULTS) return copy.deepcopy(_DEFAULT_TAB) @@ -203,8 +258,8 @@ def merge_display( for key, value in submitted.items(): if isinstance(value, dict): out[key] = copy.deepcopy(value) - # Drop head from item_3d/gun defaults unless player supplied it. - if kind in ("item_3d", "gun") and HEAD_TAB not in (submitted or {}): + # Drop head from held-item defaults unless player supplied it. + if kind in _HELD_KINDS and HEAD_TAB not in (submitted or {}): out.pop(HEAD_TAB, None) return out diff --git a/backend/src/skins/naming.py b/backend/src/skins/naming.py index d2b028f8..4bdd929f 100644 --- a/backend/src/skins/naming.py +++ b/backend/src/skins/naming.py @@ -8,6 +8,8 @@ import re +from .base_sets import armor_single_sets + SLUG_RE = re.compile(r"^[a-z][a-z0-9_]{1,47}$") RESERVED = frozenset( @@ -39,14 +41,26 @@ def parse_armor_tier(tier: str) -> tuple[str, str] | None: return None +def is_armor_single_set(tier: str) -> bool: + """A set that is its own line, like ``leather`` or ``wild_mage`` (shared base_sets.json).""" + return (tier or "") in armor_single_sets() + + def armor_tier_base_set(tier: str) -> str: """ArmourShop base set for a tier. Older bare tiers (``iron``, ``mage``) are their own set.""" parsed = parse_armor_tier(tier) - return f"{parsed[0]} {parsed[1]}" if parsed else tier + if parsed: + return f"{parsed[0]} {parsed[1]}" + if is_armor_single_set(tier): + return tier.replace("_", " ") + return tier def armor_tier_label(tier: str) -> str: """Default display suffix: ``Light Iron`` (matches shop YAML like "Osenic Iron").""" + single = armor_single_sets().get(tier or "") + if single: + return single return " ".join(part.capitalize() for part in armor_tier_base_set(tier).split()) @@ -59,6 +73,11 @@ def armor_tier_label(tier: str) -> str: BOW_PULL_FIELDS = ("pull_0", "pull_1", "pull_2") BOW_FRAME_FIELDS = ("texture",) + BOW_PULL_FIELDS CROSSBOW_FRAME_FIELDS = BOW_FRAME_FIELDS + ("charged",) +# 3D bows: one texture, a model per bow state. +BOW_3D_MODEL_FIELDS = ("model", "pull_0_model", "pull_1_model", "pull_2_model") +CROSSBOW_3D_MODEL_FIELDS = BOW_3D_MODEL_FIELDS + ("charged_model",) +# Optional animation for a PNG field: ``{field}_mcmeta``. Never on armour layers. +MCMETA_SUFFIX = "_mcmeta" BOOK_FIELDS = ("unsigned", "signed") diff --git a/backend/src/skins/preview_3d.py b/backend/src/skins/preview_3d.py index 155c7767..e660c747 100644 --- a/backend/src/skins/preview_3d.py +++ b/backend/src/skins/preview_3d.py @@ -14,6 +14,7 @@ import tempfile from pathlib import Path +from .animation import first_frame_png, mcmeta_path from .storage import ( BOOK_KIND, BOW_KINDS, @@ -21,6 +22,7 @@ GUN_KIND, ITEM_KINDS, MODEL_3D_KINDS, + RANGED_3D_KINDS, ) log = logging.getLogger("skins.preview_3d") @@ -42,6 +44,8 @@ "body": "preview_body.png", "book_unsigned": "preview_book_unsigned.png", "book_signed": "preview_book_signed.png", + "pull_2": "preview_pull_2.png", + "charged": "preview_charged.png", } @@ -97,8 +101,9 @@ def _mtime(path: Path) -> float: def _preview_inputs(files: dict[str, str]) -> list[Path]: - """Model/texture paths plus the renderer bundle, so a rebuild refreshes tiles.""" + """Model/texture paths (and their .mcmeta) plus the renderer bundle, so a rebuild refreshes tiles.""" inputs = [Path(p) for p in files.values()] + inputs += [mcmeta_path(p) for p in inputs if p.suffix.lower() == ".png"] if isinstance(BUNDLE, Path): inputs.append(BUNDLE) return inputs @@ -185,6 +190,24 @@ def _job_for_kind(kind: str, slug: str, out_dir: Path, tiers: list[str]) -> dict views.append("hat") return {"kind": kind, "views": views, "files": files} + if kind in RANGED_3D_KINDS: + if "texture" not in files: + return None + model = out_dir / f"{slug}.json" + if model.is_file(): + files["model"] = str(model) + views = ["model"] + # Fully drawn, and loaded for crossbows: the states that differ most from standby. + extra = [("pull_2", "_2")] + if kind == "crossbow_3d": + extra.append(("charged", "_charged")) + for view, suffix in extra: + path = out_dir / f"{slug}{suffix}.json" + if path.is_file(): + files[view] = str(path) + views.append(view) + return {"kind": kind, "views": views, "files": files} + if kind == GUN_KIND: if "texture" not in files: return None @@ -199,6 +222,22 @@ def _job_for_kind(kind: str, slug: str, out_dir: Path, tiers: list[str]) -> dict return None +def _first_frame_files(files: dict[str, str], tmp_dir: Path) -> dict[str, str]: + """Swap animated textures for a crop of the frame Minecraft shows first.""" + out = dict(files) + for key, raw in files.items(): + path = Path(raw) + if path.suffix.lower() != ".png": + continue + frame = first_frame_png(path) + if frame is None: + continue + crop = tmp_dir / path.name + crop.write_bytes(frame) + out[key] = str(crop) + return out + + def ensure_preview_tiles( kind: str, slug: str, @@ -239,14 +278,15 @@ def ensure_preview_tiles( log.warning("3D sheet renderer unavailable: backend/render/dist/browser.js missing") return tiles, "3D renderer unavailable: run npm install in backend/render" - payload = { - "kind": job["kind"], - "views": job["views"], - "files": job["files"], - "outDir": str(out_dir), - } render_error: str | None = None try: + frame_dir = tempfile.TemporaryDirectory() + payload = { + "kind": job["kind"], + "views": job["views"], + "files": _first_frame_files(job["files"], Path(frame_dir.name)), + "outDir": str(out_dir), + } with tempfile.NamedTemporaryFile( "w", suffix=".json", delete=False, encoding="utf-8" ) as tmp: @@ -265,6 +305,7 @@ def ensure_preview_tiles( os.unlink(tmp_path) except OSError: pass + frame_dir.cleanup() if result.returncode != 0: err = (result.stderr or result.stdout or "").strip() render_error = ( diff --git a/backend/src/skins/review_sheet.py b/backend/src/skins/review_sheet.py index c35765f8..ae04c8b9 100644 --- a/backend/src/skins/review_sheet.py +++ b/backend/src/skins/review_sheet.py @@ -13,6 +13,7 @@ from src import database +from .animation import first_frame, frame_count from .db import SKINS_DIR from .name_preview import render_name_preview_png from .naming import ARMOR_FIELDS @@ -33,6 +34,7 @@ GUN_KIND, ITEM_KINDS, MODEL_3D_KINDS, + RANGED_3D_KINDS, ) TILE_DISPLAY = 128 @@ -67,14 +69,21 @@ def _font(size: int = 14) -> ImageFont.ImageFont: def _load_rgba(path: Path) -> Image.Image: + """The PNG, or for an animated texture the frame Minecraft shows first.""" try: img = Image.open(path) img.load() - return img.convert("RGBA") + return first_frame(path, img.convert("RGBA")) except OSError as e: raise ReviewSheetError(f"Cannot read image: {path.name}") from e +def _animated_note(path: Path) -> str: + """`` · animated · N frames`` for a PNG with an .mcmeta, else empty.""" + frames = frame_count(path) + return f" · animated · {frames} frames" if frames > 1 else "" + + def _scale_nn(img: Image.Image, max_side: int) -> Image.Image: w, h = img.size if w <= 0 or h <= 0: @@ -208,6 +217,7 @@ def _armor_tier_strip(tier: str, out_dir: Path) -> Image.Image: label = "helmet_3d" if field == "helmet" and path.name.endswith( "_helmet_texture.png" ) else field + label += _animated_note(path) bbox = draw.textbbox((0, 0), label, font=font) tw = bbox[2] - bbox[0] draw.text( @@ -265,7 +275,7 @@ def _single_texture_body( raise ReviewSheetError(f"Missing file: {path.name}") display = ITEM_DISPLAY - caption = f"kind={kind}" + caption = f"kind={kind}{_animated_note(path)}" if base_set: caption += f" base={base_set}" if grip_preset: @@ -339,6 +349,7 @@ def _bow_body( _paste_centered( canvas, tile, (x0, y0, x0 + BOW_DISPLAY, y0 + BOW_DISPLAY) ) + label += _animated_note(path) bbox = draw.textbbox((0, 0), label, font=font) tw = bbox[2] - bbox[0] draw.text( @@ -400,6 +411,7 @@ def _book_body( _paste_centered( canvas, tile, (x0, y0, x0 + BOW_DISPLAY, y0 + BOW_DISPLAY) ) + label += _animated_note(path) bbox = draw.textbbox((0, 0), label, font=font) tw = bbox[2] - bbox[0] draw.text( @@ -436,6 +448,8 @@ def _book_body( "body": "full body", "book_unsigned": "3D unsigned", "book_signed": "3D signed", + "pull_2": "3D pull 2", + "charged": "3D charged", } @@ -499,7 +513,12 @@ def _compose_full_sheet(row, out_dir: Path) -> Image.Image: if base_set: caption_bits.append(f"base={base_set}") body = _book_body(slug, base_set, out_dir) - elif kind in ITEM_KINDS or kind in MODEL_3D_KINDS or kind == GUN_KIND: + elif ( + kind in ITEM_KINDS + or kind in MODEL_3D_KINDS + or kind in RANGED_3D_KINDS + or kind == GUN_KIND + ): if base_set: caption_bits.append(f"base={base_set}") if grip: diff --git a/backend/src/skins/size_limits.py b/backend/src/skins/size_limits.py index a16b5c87..93e6e467 100644 --- a/backend/src/skins/size_limits.py +++ b/backend/src/skins/size_limits.py @@ -1,7 +1,12 @@ -"""Combined texture+model byte budgets for 3D skin kinds.""" +"""Combined texture+model byte budgets for 3D skin kinds. + +A texture's optional ``.mcmeta`` counts toward its pair. +""" from __future__ import annotations +from .base_sets import ranged_3d_total_bytes + class SizeLimitError(ValueError): """Texture + model pair exceeds configured budget.""" @@ -17,8 +22,9 @@ def assert_pair( max_bytes: int, *, label: str, + mcmeta: bytes | None = None, ) -> None: - total = _pair_len(texture, model) + total = _pair_len(texture, model) + len(mcmeta or b"") cap = max(0, int(max_bytes)) if total > cap: raise SizeLimitError( @@ -38,12 +44,23 @@ def assert_3d_pair_budgets( k = (kind or "").strip() cap = max(0, int(max_bytes)) + if k in ("bow_3d", "crossbow_3d"): + # One fixed budget for every rank, across all of the upload's files. + limit = ranged_3d_total_bytes() + total = sum(len(data or b"") for data in files_bytes.values()) + if total > limit: + raise SizeLimitError( + f"{k}: texture + models is {total} bytes (limit {limit} bytes)" + ) + return + if k in ("item_3d", "shield", "helmet_3d", "mask"): assert_pair( files_bytes.get("texture"), files_bytes.get("model"), cap, label=k, + mcmeta=files_bytes.get("texture_mcmeta"), ) return @@ -55,6 +72,7 @@ def assert_3d_pair_budgets( files_bytes.get(stem), cap, label=f"gun/{stem}", + mcmeta=files_bytes.get("texture_mcmeta"), ) return @@ -68,4 +86,5 @@ def assert_3d_pair_budgets( files_bytes.get(f"{t}_helmet_model"), cap, label=f"armor_set/{t}_helmet", + mcmeta=files_bytes.get(f"{t}_helmet_texture_mcmeta"), ) diff --git a/backend/src/skins/storage.py b/backend/src/skins/storage.py index a3d21f87..457a6e5d 100644 --- a/backend/src/skins/storage.py +++ b/backend/src/skins/storage.py @@ -8,13 +8,17 @@ from datetime import datetime, timezone from pathlib import Path +from .animation import AnimationError, mcmeta_bytes, parse_mcmeta from .db import SKINS_DIR from .display import DisplayError, parse_and_merge_model from .naming import ( ARMOR_LAYER_FIELDS, BOOK_FIELDS, + BOW_3D_MODEL_FIELDS, BOW_FRAME_FIELDS, + CROSSBOW_3D_MODEL_FIELDS, CROSSBOW_FRAME_FIELDS, + MCMETA_SUFFIX, ) from .pack_models.gun import build_gun_models from .pack_models.large_bow import bow_file_suffix, build_large_bow_models @@ -42,6 +46,16 @@ BOW_KINDS = frozenset({"bow", "large_bow"}) CROSSBOW_KINDS = frozenset({"crossbow"}) +# One texture and a model per bow state; see naming.BOW_3D_MODEL_FIELDS. +RANGED_3D_KINDS = frozenset({"bow_3d", "crossbow_3d"}) +# Model field → file suffix after the slug ({slug}.json, {slug}_0.json, …). +RANGED_3D_MODEL_SUFFIX = { + "model": "", + "pull_0_model": "_0", + "pull_1_model": "_1", + "pull_2_model": "_2", + "charged_model": "_charged", +} ITEM_KINDS = frozenset({"handheld", "large_handheld", "item"}) MODEL_3D_KINDS = frozenset({"item_3d", "shield", "helmet_3d", "mask"}) GUN_KIND = "gun" @@ -90,6 +104,58 @@ def validate_model_json_size(data: bytes) -> None: ) +def ranged_3d_model_fields(kind: str) -> tuple[str, ...]: + return CROSSBOW_3D_MODEL_FIELDS if kind == "crossbow_3d" else BOW_3D_MODEL_FIELDS + + +def check_mcmeta_fields(files: dict[str, bytes]) -> None: + """Every ``{field}_mcmeta`` needs an uploaded PNG ``field`` that is not an armour layer.""" + for key in files: + if not key.endswith(MCMETA_SUFFIX): + continue + field = key[: -len(MCMETA_SUFFIX)] + if any(field.endswith(f"_{layer}") or field == layer for layer in ARMOR_LAYER_FIELDS): + raise StorageError( + f"{key}: armour layer textures cannot be animated" + ) + data = files.get(field) + if not data or not data.startswith(PNG_MAGIC): + raise StorageError(f"{key}: no PNG '{field}' to animate") + + +def _write_png( + out_dir: Path, + files: dict[str, bytes], + field: str, + names: tuple[str, ...], + expected: tuple[int, int] | None = None, +) -> None: + """Validate ``field`` and write it as each of ``names``, with ``{name}.mcmeta`` if animated. + + An animated PNG with a fixed ``expected`` size must be a grid of frames that size. + """ + if field not in files: + raise StorageError(f"Missing file field: {field}") + data = files[field] + meta = files.get(field + MCMETA_SUFFIX) + clean: bytes | None = None + if meta is None: + validate_png(data, expected) + else: + validate_png(data) + try: + parsed, _ = parse_mcmeta( + meta, png_dimensions(data), expected, label=field + ) + except AnimationError as e: + raise StorageError(str(e)) from e + clean = mcmeta_bytes(parsed) + for name in names: + (out_dir / name).write_bytes(data) + if clean is not None: + (out_dir / f"{name}.mcmeta").write_bytes(clean) + + def _iso_now() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") @@ -111,19 +177,16 @@ def _write_bow_frames( ) -> None: size = _expected_bow_size(kind) for field in fields: - if field not in files: - raise StorageError(f"Missing file field: {field}") - validate_png(files[field], size) if field == "texture": - (out_dir / f"{slug}.png").write_bytes(files[field]) + names: tuple[str, ...] = (f"{slug}.png",) elif field.startswith("pull_"): n = field.split("_", 1)[1] - (out_dir / f"{slug}_{n}.png").write_bytes(files[field]) + names = (f"{slug}_{n}.png",) elif field == "charged": - (out_dir / f"{slug}_charged.png").write_bytes(files[field]) - (out_dir / f"{slug}_arrow.png").write_bytes(files[field]) + names = (f"{slug}_charged.png", f"{slug}_arrow.png") else: raise StorageError(f"Unknown bow field: {field}") + _write_png(out_dir, files, field, names, size) def _write_model_3d( @@ -133,13 +196,12 @@ def _write_model_3d( raise StorageError("Missing file field: texture") if "model" not in files: raise StorageError("Missing file field: model") - validate_png(files["texture"]) validate_model_json_size(files["model"]) try: model = parse_and_merge_model(files["model"], kind) except DisplayError as e: raise StorageError(str(e)) from e - (out_dir / f"{slug}.png").write_bytes(files["texture"]) + _write_png(out_dir, files, "texture", (f"{slug}.png",)) if kind == "shield": idle, blocking = build_shield_models(model, slug) (out_dir / f"{slug}.json").write_bytes(pack_model_to_bytes(idle)) @@ -151,11 +213,28 @@ def _write_model_3d( (out_dir / f"{slug}.json").write_bytes(pack_model_to_bytes(normalized)) +def _write_ranged_3d( + out_dir: Path, slug: str, kind: str, files: dict[str, bytes] +) -> None: + """3D bow / crossbow: {slug}.png plus {slug}.json, _0, _1, _2 (and _charged).""" + built: dict[str, bytes] = {} + for field in ranged_3d_model_fields(kind): + if field not in files: + raise StorageError(f"Missing file field: {field}") + validate_model_json_size(files[field]) + try: + model = parse_and_merge_model(files[field], kind) + except DisplayError as e: + raise StorageError(f"{field}: {e}") from e + name = f"{slug}{RANGED_3D_MODEL_SUFFIX[field]}.json" + built[name] = pack_model_to_bytes(normalize_textures(model, slug)) + _write_png(out_dir, files, "texture", (f"{slug}.png",)) + for name, data in built.items(): + (out_dir / name).write_bytes(data) + + def _write_gun(out_dir: Path, slug: str, files: dict[str, bytes]) -> None: - if "texture" not in files: - raise StorageError("Missing file field: texture") - validate_png(files["texture"]) - (out_dir / f"{slug}.png").write_bytes(files["texture"]) + _write_png(out_dir, files, "texture", (f"{slug}.png",)) merged: dict[str, dict] = {} for field, stem in zip(GUN_MODEL_FIELDS, GUN_MODEL_STEMS, strict=True): if field not in files: @@ -173,14 +252,11 @@ def _write_gun(out_dir: Path, slug: str, files: dict[str, bytes]) -> None: def _write_large_handheld( out_dir: Path, slug: str, files: dict[str, bytes], grip_preset: str | None ) -> None: - if "texture" not in files: - raise StorageError("Missing file field: texture") - validate_png(files["texture"], LARGE_HANDHELD_SIZE) try: grip = parse_grip_preset(grip_preset) except ValueError as e: raise StorageError(str(e)) from e - (out_dir / f"{slug}.png").write_bytes(files["texture"]) + _write_png(out_dir, files, "texture", (f"{slug}.png",), LARGE_HANDHELD_SIZE) (out_dir / f"{slug}.json").write_bytes( pack_model_to_bytes(build_large_handheld_model(slug, grip)) ) @@ -209,10 +285,7 @@ def _write_armor( for field in ("chestplate", "leggings", "boots"): key = f"{tier}_{field}" - if key not in files: - raise StorageError(f"Missing file field: {key}") - validate_png(files[key], ICON_SIZE) - (out_dir / f"{tier}_{field}.png").write_bytes(files[key]) + _write_png(out_dir, files, key, (f"{key}.png",), ICON_SIZE) if tier in helmet_3d: model_key = f"{tier}_helmet_model" @@ -221,7 +294,6 @@ def _write_armor( raise StorageError(f"Missing file field: {model_key}") if tex_key not in files: raise StorageError(f"Missing file field: {tex_key}") - validate_png(files[tex_key]) validate_model_json_size(files[model_key]) try: model = parse_and_merge_model( @@ -229,9 +301,7 @@ def _write_armor( ) except DisplayError as e: raise StorageError(str(e)) from e - (out_dir / f"{tier}_helmet_texture.png").write_bytes( - files[tex_key] - ) + _write_png(out_dir, files, tex_key, (f"{tex_key}.png",)) # Pack slug at apply is {submission_id}_{tier}; texture id matches. texture_id = f"{out_dir.name}_{tier}_helmet" normalized = normalize_textures(model, texture_id) @@ -240,10 +310,7 @@ def _write_armor( ) else: key = f"{tier}_helmet" - if key not in files: - raise StorageError(f"Missing file field: {key}") - validate_png(files[key], ICON_SIZE) - (out_dir / f"{tier}_helmet.png").write_bytes(files[key]) + _write_png(out_dir, files, key, (f"{key}.png",), ICON_SIZE) def write_submission_files( @@ -271,6 +338,7 @@ def write_submission_files( h3d = list(helmet_3d_tiers or []) try: + check_mcmeta_fields(files) if kind == "armor_set": tier_list = list(tiers or []) if not tier_list: @@ -279,10 +347,7 @@ def write_submission_files( elif kind == "large_handheld": _write_large_handheld(out_dir, slug, files, grip_preset) elif kind == "handheld": - if "texture" not in files: - raise StorageError("Missing file field: texture") - validate_png(files["texture"], ITEM_SIZE) - (out_dir / f"{slug}.png").write_bytes(files["texture"]) + _write_png(out_dir, files, "texture", (f"{slug}.png",), ITEM_SIZE) elif kind == "large_bow": _write_bow_frames(out_dir, slug, kind, files, BOW_FRAME_FIELDS) _write_large_bow_models(out_dir, slug) @@ -294,14 +359,15 @@ def write_submission_files( ) elif kind in MODEL_3D_KINDS: _write_model_3d(out_dir, slug, kind, files) + elif kind in RANGED_3D_KINDS: + _write_ranged_3d(out_dir, slug, kind, files) elif kind == GUN_KIND: _write_gun(out_dir, slug, files) elif kind == BOOK_KIND: for field in BOOK_FIELDS: - if field not in files: - raise StorageError(f"Missing file field: {field}") - validate_png(files[field], ITEM_SIZE) - (out_dir / f"{slug}_{field}.png").write_bytes(files[field]) + _write_png( + out_dir, files, field, (f"{slug}_{field}.png",), ITEM_SIZE + ) else: raise StorageError(f"Unsupported kind: {kind}") diff --git a/backend/src/skins/submissions.py b/backend/src/skins/submissions.py index f4f8a034..0bf41bca 100644 --- a/backend/src/skins/submissions.py +++ b/backend/src/skins/submissions.py @@ -11,6 +11,7 @@ from .db import SKINS_DIR from .codes import CodeError, SCOPE_LORE_UPLOAD, api_time, lock_code_for_submission, mark_code_consumed +from .base_sets import base_sets_by_kind from .discord_link import get_link_for_uuid from .naming import ( ARMOR_METALS, @@ -23,6 +24,7 @@ SlugError, armor_tier_base_set, armor_tier_label, + is_armor_single_set, build_staff_submission_id, build_submission_id, build_submission_id_for_realm, @@ -37,7 +39,9 @@ CROSSBOW_KINDS, GUN_KIND, GUN_MODEL_FIELDS, + RANGED_3D_KINDS, StorageError, + ranged_3d_model_fields, write_submission_files, ) from src.name_colours import NameColourError, validate_name_colours @@ -50,23 +54,6 @@ ALLOWED_STYLES = frozenset( {"bold", "italic", "underline", "underlined", "strikethrough", "strike"} ) -_HANDHELD_BASES = frozenset( - { - "swords", - "lutes", - "battleaxes", - "daggers", - "warhammers", - "shortswords", - "hatchets", - "hoes", - "knives", - } -) -_LARGE_HANDHELD_BASES = frozenset( - {"spears", "polearms", "greathammers", "staffs"} -) - ALLOWED_KINDS = frozenset( { "armor_set", @@ -75,6 +62,8 @@ "bow", "large_bow", "crossbow", + "bow_3d", + "crossbow_3d", "item_3d", "shield", "helmet_3d", @@ -84,19 +73,8 @@ } ) # Armour has no base_set: its sets come from tiers (see _validate_tiers). -BASE_SETS: dict[str, frozenset[str]] = { - "handheld": _HANDHELD_BASES, - "large_handheld": _LARGE_HANDHELD_BASES, - "bow": frozenset({"shortbows"}), - "large_bow": frozenset({"longbows"}), - "crossbow": frozenset({"crossbows"}), - "item_3d": _HANDHELD_BASES | _LARGE_HANDHELD_BASES, - "shield": frozenset({"shields"}), - "helmet_3d": frozenset({"helmets"}), - "mask": frozenset({"masks"}), - "gun": frozenset({"rifles", "pistols", "shotguns", "launchers"}), - "book": frozenset({"books"}), -} +# Everything else comes from shared/skins/base_sets.json. +BASE_SETS: dict[str, frozenset[str]] = base_sets_by_kind() MODEL_3D_KINDS = frozenset({"item_3d", "shield", "helmet_3d", "mask"}) GUN_FIELDS = ("texture",) + GUN_MODEL_FIELDS GRIP_Y_MIN = 0.0 @@ -282,9 +260,17 @@ def _validate_base_set(kind: str, base_set: str | None) -> str: def _validate_tiers(raw: list[str] | None) -> list[str]: - """One metal line: ``{type}_{metal}`` tiers, at most one per type, one metal.""" + """One metal line (``{type}_{metal}`` tiers, at most one per type) or one single set.""" if not raw: raise SubmissionError(f"armor_set requires 1–{MAX_ARMOR_SETS} sets") + singles = [t for t in raw if is_armor_single_set((t or "").strip().lower())] + if singles: + if len(raw) != 1: + raise SubmissionError( + f"'{singles[0].strip().lower()}' is a set of its own; " + "upload it without other armour sets" + ) + return [singles[0].strip().lower()] if len(raw) > MAX_ARMOR_SETS: raise SubmissionError(f"at most {MAX_ARMOR_SETS} armor sets") out: list[str] = [] @@ -593,8 +579,8 @@ def create_submission( raise SubmissionError("kind 'item' is disabled") if kind not in ALLOWED_KINDS: raise SubmissionError( - "kind must be armor_set, handheld, large_handheld, " - "bow, large_bow, crossbow, item_3d, shield, helmet_3d, mask, gun, or book" + "kind must be armor_set, handheld, large_handheld, bow, large_bow, " + "crossbow, bow_3d, crossbow_3d, item_3d, shield, helmet_3d, mask, gun, or book" ) try: @@ -717,6 +703,14 @@ def create_submission( raise SubmissionError("Missing file: texture") if "model" not in files_bytes: raise SubmissionError("Missing file: model") + elif kind in RANGED_3D_KINDS: + missing = [ + f + for f in ("texture",) + ranged_3d_model_fields(kind) + if f not in files_bytes + ] + if missing: + raise SubmissionError(f"Missing files: {', '.join(missing)}") elif kind == GUN_KIND: missing = [f for f in GUN_FIELDS if f not in files_bytes] if missing: @@ -1090,7 +1084,7 @@ def _get_row(submission_id: str) -> dict | None: def _list_asset_files(submission_id: str) -> list[str]: - """PNG + model JSON for plugin download (excludes meta.json).""" + """PNG, .png.mcmeta and model JSON for plugin download (excludes meta.json).""" out_dir = SKINS_DIR / submission_id if not out_dir.is_dir(): return [] @@ -1100,6 +1094,7 @@ def _list_asset_files(submission_id: str) -> list[str]: if p.is_file() and ( p.suffix.lower() == ".png" + or p.name.lower().endswith(".png.mcmeta") or ( p.suffix.lower() == ".json" and p.name.lower() != "meta.json" diff --git a/backend/src/skins/test_ranged_3d_and_animation.py b/backend/src/skins/test_ranged_3d_and_animation.py new file mode 100644 index 00000000..8d85b18a --- /dev/null +++ b/backend/src/skins/test_ranged_3d_and_animation.py @@ -0,0 +1,409 @@ +"""3D bows and crossbows, animated (.mcmeta) textures, shared base sets, single armour sets.""" + +from __future__ import annotations + +import io +import json +from pathlib import Path + +import pytest +from PIL import Image + +from src.skins import storage +from src.skins.animation import AnimationError, first_frame, frame_count, parse_mcmeta +from src.skins.base_sets import base_sets_by_kind, load_base_sets +from src.skins.size_limits import SizeLimitError, assert_3d_pair_budgets +from src.skins.storage import StorageError, write_submission_files + +MODEL = { + "elements": [ + { + "from": [7, 0, 7], + "to": [9, 16, 9], + "faces": {"north": {"uv": [0, 0, 2, 16], "texture": "#0"}}, + } + ], + "textures": {"0": "my_bow"}, +} + + +def _png(size: tuple[int, int], colours: list[tuple[int, int, int, int]] | None = None) -> bytes: + """A PNG of 16-tall frames stacked downwards, each frame its own colour.""" + img = Image.new("RGBA", size, (0, 0, 0, 0)) + for i, colour in enumerate(colours or []): + img.paste(colour, (0, i * size[0], size[0], (i + 1) * size[0])) + buf = io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + + +def _model(**extra) -> bytes: + return json.dumps({**MODEL, **extra}).encode() + + +def _meta(**animation) -> bytes: + return json.dumps({"animation": animation}).encode() + + +@pytest.fixture() +def skins_dir(tmp_path, monkeypatch) -> Path: + monkeypatch.setattr(storage, "SKINS_DIR", tmp_path) + return tmp_path + + +def _write(kind: str, files: dict[str, bytes], **kwargs) -> Path: + return write_submission_files("sub1", "p_bow", kind, "Bow", files, **kwargs) + + +# --- base sets ---------------------------------------------------------------- + + +def test_base_sets_come_from_shared_json(): + sets = base_sets_by_kind() + for wanted in ("wands", "mage blades", "pickaxes", "shovels", "fishing rods", "instruments"): + assert wanted in sets["handheld"] + assert wanted in sets["item_3d"] + for wanted in ("longswords", "greataxes"): + assert wanted in sets["large_handheld"] + assert wanted in sets["item_3d"] + assert "battle standards" in sets["item_3d"] + assert "battle standards" not in sets["handheld"] + assert sets["bow_3d"] == {"shortbows", "longbows"} + assert sets["crossbow_3d"] == {"crossbows"} + assert "armor_set" not in sets + + +def test_every_base_set_kind_is_an_upload_kind(): + from src.skins.submissions import ALLOWED_KINDS, BASE_SETS + + data = load_base_sets() + groups = {g["id"] for g in data["groups"]} + for row in data["base_sets"]: + assert row["group"] in groups + assert set(row["kinds"]) <= ALLOWED_KINDS + assert BASE_SETS == base_sets_by_kind() + + +def test_spaced_base_set_is_kept_verbatim(): + from src.skins.submissions import _validate_base_set + + assert _validate_base_set("handheld", "mage blades") == "mage blades" + assert _validate_base_set("bow_3d", "longbows") == "longbows" + + +# --- single armour sets ------------------------------------------------------- + + +def test_single_armour_sets(): + from src.skins.naming import armor_tier_base_set, armor_tier_label + from src.skins.submissions import SubmissionError, _validate_tiers + + assert _validate_tiers(["wild_mage"]) == ["wild_mage"] + assert armor_tier_base_set("wild_mage") == "wild mage" + assert armor_tier_base_set("leather") == "leather" + assert armor_tier_label("light_mage") == "Light Mage" + assert armor_tier_label("bloodmagic") == "Bloodmagic" + with pytest.raises(SubmissionError, match="set of its own"): + _validate_tiers(["leather", "light_iron"]) + with pytest.raises(SubmissionError, match="set of its own"): + _validate_tiers(["illusion", "arcanum"]) + + +# --- size budget -------------------------------------------------------------- + + +def test_ranged_3d_budget_counts_every_file_for_every_rank(): + files = {"texture": b"t" * 20000, "model": b"m" * 20000, "pull_0_model": b"m" * 20000} + # Far above the per-rank pair cap, still inside the fixed 60 KiB. + assert_3d_pair_budgets("bow_3d", files, 0) + files["texture_mcmeta"] = b"x" * 2000 + with pytest.raises(SizeLimitError, match=r"bow_3d: texture \+ models is 62000 bytes \(limit 61440 bytes\)"): + assert_3d_pair_budgets("bow_3d", files, 10**9) + + +def test_texture_mcmeta_counts_toward_item_3d_pair(): + with pytest.raises(SizeLimitError): + assert_3d_pair_budgets( + "item_3d", + {"texture": b"t" * 100, "model": b"m" * 100, "texture_mcmeta": b"x" * 100}, + 250, + ) + + +# --- 3D bows ------------------------------------------------------------------ + + +def test_bow_3d_writes_texture_and_four_models(skins_dir): + files = {"texture": _png((16, 16))} + for field in ("model", "pull_0_model", "pull_1_model", "pull_2_model"): + files[field] = _model() + out = _write("bow_3d", files, base_set="longbows") + names = sorted(p.name for p in out.iterdir()) + assert names == ["meta.json", "p_bow.json", "p_bow.png", "p_bow_0.json", "p_bow_1.json", "p_bow_2.json"] + model = json.loads((out / "p_bow_2.json").read_text()) + assert model["textures"]["0"].endswith(":item/p_bow") + # Vanilla bow hand pose fills a missing display tab. + assert model["display"]["thirdperson_righthand"]["rotation"] == [-80, 260, -40] + assert "head" not in model["display"] + + +def test_crossbow_3d_needs_charged_model(skins_dir): + files = {"texture": _png((16, 16))} + for field in ("model", "pull_0_model", "pull_1_model", "pull_2_model"): + files[field] = _model() + with pytest.raises(StorageError, match="charged_model"): + _write("crossbow_3d", files) + files["charged_model"] = _model() + out = _write("crossbow_3d", files) + assert (out / "p_bow_charged.json").is_file() + charged = json.loads((out / "p_bow_charged.json").read_text()) + assert charged["display"]["thirdperson_righthand"]["rotation"] == [-90, 0, -60] + + +def test_bow_3d_reports_which_model_is_bad(skins_dir): + files = {"texture": _png((16, 16)), "model": _model(), "pull_0_model": _model(), + "pull_1_model": b'{"elements": []}', "pull_2_model": _model()} + with pytest.raises(StorageError, match="pull_1_model"): + _write("bow_3d", files) + + +# --- animation ---------------------------------------------------------------- + + +RED, GREEN, BLUE = (255, 0, 0, 255), (0, 255, 0, 255), (0, 0, 255, 255) + + +def test_animated_handheld_is_a_strip_of_16px_frames(skins_dir): + meta = json.dumps({"animation": {"frametime": 4, "frames": [1, {"index": 0, "time": 8}]}}, indent=4).encode() + out = _write("handheld", {"texture": _png((16, 48), [RED, GREEN, BLUE]), "texture_mcmeta": meta}) + stored = out / "p_bow.png.mcmeta" + assert json.loads(stored.read_text()) == { + "animation": {"frametime": 4, "frames": [1, {"index": 0, "time": 8}]} + } + assert b"\n" not in stored.read_bytes() + png = out / "p_bow.png" + assert frame_count(png) == 3 + with Image.open(png) as im: + frame = first_frame(png, im.convert("RGBA")) + assert frame.size == (16, 16) + assert frame.getpixel((0, 0)) == GREEN + + +def test_still_texture_keeps_its_exact_size(skins_dir): + with pytest.raises(StorageError, match="16x16"): + _write("handheld", {"texture": _png((16, 48))}) + + +def test_animated_frames_must_match_the_kind_size(skins_dir): + with pytest.raises(StorageError, match="frames must be 16x16, got 32x32"): + _write("handheld", {"texture": _png((32, 64)), "texture_mcmeta": _meta()}) + + +def test_flat_crossbow_charged_animation_follows_its_arrow_copy(skins_dir): + files = { + "texture": _png((16, 16), [RED]), + "pull_0": _png((16, 16), [GREEN]), + "pull_1": _png((16, 16), [BLUE]), + "pull_2": _png((16, 16), [(1, 2, 3, 255)]), + "charged": _png((16, 32), [RED, BLUE]), + "charged_mcmeta": _meta(frametime=2), + } + out = _write("crossbow", files) + assert (out / "p_bow_charged.png.mcmeta").is_file() + assert (out / "p_bow_arrow.png.mcmeta").is_file() + assert not (out / "p_bow.png.mcmeta").exists() + + +def test_armour_layers_cannot_be_animated(skins_dir): + with pytest.raises(StorageError, match="armour layer textures cannot be animated"): + _write( + "armor_set", + {"light_iron_layer_1": _png((64, 32)), "light_iron_layer_1_mcmeta": _meta()}, + tiers=["light_iron"], + ) + + +def test_mcmeta_needs_its_png(skins_dir): + with pytest.raises(StorageError, match="no PNG 'model'"): + _write("item_3d", {"texture": _png((16, 16)), "model": _model(), "model_mcmeta": _meta()}) + + +def test_failed_upload_leaves_no_files(skins_dir): + with pytest.raises(StorageError): + _write("handheld", {"texture": _png((16, 32)), "texture_mcmeta": b"{"}) + assert not (skins_dir / "sub1").exists() + + +@pytest.mark.parametrize( + ("meta", "image", "message"), + [ + (b"[]", (16, 32), 'only an "animation" section'), + (b'{"animation": {}, "villager": {}}', (16, 32), 'only an "animation" section'), + (_meta(frametime=0), (16, 32), "frametime"), + (_meta(frametime=True), (16, 32), "frametime"), + (_meta(interpolate="yes"), (16, 32), "interpolate"), + (_meta(blur=True), (16, 32), "unsupported keys: blur"), + (_meta(frames=[]), (16, 32), "non-empty list"), + (_meta(frames=[2]), (16, 32), "frame 2 does not exist"), + (_meta(frames=[{"time": 2}]), (16, 32), "need an index"), + (_meta(frames=[{"index": 0, "time": 0}]), (16, 32), "time must be"), + (_meta(width=10), (16, 32), "whole grid"), + (b"\xff", (16, 32), "not valid JSON"), + (b" " * 3000, (16, 32), "limit 2048 bytes"), + ], +) +def test_bad_mcmeta(meta, image, message): + with pytest.raises(AnimationError, match=message): + parse_mcmeta(meta, image) + + +def test_mcmeta_frame_size_rules(): + # Square frames of the shorter side by default; width/height override. + assert parse_mcmeta(_meta(), (16, 64))[1] == 4 + assert parse_mcmeta(_meta(width=8), (16, 64))[1] == 2 + assert parse_mcmeta(_meta(height=8), (16, 64))[1] == 8 + assert parse_mcmeta(_meta(width=8, height=16), (16, 64))[1] == 8 + assert parse_mcmeta(_meta(width=16, height=16, frames=[3]), (32, 32))[1] == 4 + + +def test_first_frame_of_a_grid(tmp_path): + img = Image.new("RGBA", (32, 32)) + img.paste(RED, (0, 0, 16, 16)) + img.paste(GREEN, (16, 0, 32, 16)) + img.paste(BLUE, (0, 16, 16, 32)) + png = tmp_path / "grid.png" + img.save(png) + (tmp_path / "grid.png.mcmeta").write_bytes(_meta(width=16, height=16, frames=[2, 0])) + assert frame_count(png) == 4 + assert first_frame(png, img).getpixel((0, 0)) == BLUE + + +def test_plugin_file_list_includes_mcmeta(skins_dir, monkeypatch): + from src.skins import submissions + + monkeypatch.setattr(submissions, "SKINS_DIR", skins_dir) + _write("handheld", {"texture": _png((16, 32), [RED, GREEN]), "texture_mcmeta": _meta()}) + assert submissions._list_asset_files("sub1") == ["p_bow.png", "p_bow.png.mcmeta"] + + +# --- static renders ----------------------------------------------------------- + + +def test_bow_3d_preview_job_shows_standby_and_drawn(tmp_path): + from src.skins.preview_3d import _job_for_kind + + (tmp_path / "b.png").write_bytes(_png((16, 16))) + for suffix in ("", "_0", "_1", "_2", "_charged"): + (tmp_path / f"b{suffix}.json").write_text("{}") + assert _job_for_kind("bow_3d", "b", tmp_path, [])["views"] == ["model", "pull_2"] + job = _job_for_kind("crossbow_3d", "b", tmp_path, []) + assert job["views"] == ["model", "pull_2", "charged"] + assert job["files"]["charged"].endswith("b_charged.json") + + +def test_renderer_gets_first_frame_crops(tmp_path): + from src.skins.preview_3d import _first_frame_files + + png = tmp_path / "b.png" + png.write_bytes(_png((16, 32), [RED, GREEN])) + (tmp_path / "b.png.mcmeta").write_bytes(_meta(frames=[1])) + still = tmp_path / "still.png" + still.write_bytes(_png((16, 16))) + crops = tmp_path / "crops" + crops.mkdir() + out = _first_frame_files({"texture": str(png), "other": str(still), "model": "x.json"}, crops) + assert out["other"] == str(still) and out["model"] == "x.json" + with Image.open(out["texture"]) as im: + assert im.size == (16, 16) + assert im.convert("RGBA").getpixel((0, 0)) == GREEN + + +def test_thumbnail_uses_first_frame(skins_dir, monkeypatch): + from src.skins import thumbnail + + monkeypatch.setattr(thumbnail, "SKINS_DIR", skins_dir) + _write("handheld", {"texture": _png((16, 32), [RED, GREEN]), "texture_mcmeta": _meta()}) + data = thumbnail.skin_thumbnail("sub1", "p_bow") + with Image.open(io.BytesIO(data)) as im: + assert im.size == (16, 16) + + +# --- through the real upload and plugin API ----------------------------------- + +from src.skins.test_batch_and_wire import PLUGIN, api # noqa: E402,F401 - shared fixture +from src.skins.test_locking import ENTITLEMENTS, env, session_for # noqa: E402,F401 + + +def _real_files(monkeypatch, kinds): + """The shared env fixture skips writing files; these tests need them on disk.""" + from src.skins import db, submissions + + # One folder for writing, listing and the download route's path check. + monkeypatch.setattr(storage, "SKINS_DIR", db.SKINS_DIR) + monkeypatch.setattr(submissions, "SKINS_DIR", db.SKINS_DIR) + monkeypatch.setattr(submissions, "write_submission_files", write_submission_files) + monkeypatch.setattr(submissions, "resolve_web_entitlements", lambda *a, **k: { + **ENTITLEMENTS, "skin_kinds": kinds, "max_3d_pair_bytes": 0}) + + +def test_plugin_downloads_3d_crossbow_with_animation(api, monkeypatch): + from src.skins import submissions + + _real_files(monkeypatch, ["crossbow_3d"]) + files = {"texture": _png((16, 32), [RED, GREEN]), "texture_mcmeta": _meta(frametime=3)} + for field in ("model", "pull_0_model", "pull_1_model", "pull_2_model", "charged_model"): + files[field] = _model() + made = submissions.create_submission( + session_for("skin"), "crossbow_3d", "Arm Crossbow", files, base_set="crossbows") + submissions.approve_submission(made["id"]) + row = api.get("/skins/plugin/approved", headers=PLUGIN).json()["submissions"][0] + slug = made["slug"] + assert row["kind"] == "crossbow_3d" and row["base_set"] == "crossbows" + for name in (f"{slug}.png", f"{slug}.png.mcmeta", f"{slug}.json", f"{slug}_2.json", f"{slug}_charged.json"): + assert name in row["files"] + response = api.get(f"/skins/plugin/submissions/{made['id']}/files/{slug}.png.mcmeta", headers=PLUGIN) + assert response.status_code == 200 + assert response.json() == {"animation": {"frametime": 3}} + + +def test_crossbow_3d_missing_models_are_named(api, monkeypatch): + from src.skins import submissions + + monkeypatch.setattr(submissions, "resolve_web_entitlements", lambda *a, **k: { + **ENTITLEMENTS, "skin_kinds": ["crossbow_3d"]}) + with pytest.raises(submissions.SubmissionError, match="Missing files: pull_2_model, charged_model"): + submissions.create_submission( + session_for("skin"), "crossbow_3d", "Arm Crossbow", + {"texture": _png((16, 16)), "model": _model(), "pull_0_model": _model(), "pull_1_model": _model()}, + base_set="crossbows") + + +def test_single_armour_set_reaches_the_plugin(api, monkeypatch): + from src.skins import submissions + + _real_files(monkeypatch, ["armor_set"]) + files = {} + for i, part in enumerate(("helmet", "chestplate", "leggings", "boots")): + files[f"wild_mage_{part}"] = _png((16, 16), [(i + 1, 9, 9, 255)]) + files["wild_mage_layer_1"] = _png((64, 32), [(1, 1, 1, 255)]) + files["wild_mage_layer_2"] = _png((64, 32), [(2, 2, 2, 255)]) + made = submissions.create_submission( + session_for("skin"), "armor_set", "Storm Robes", files, tiers=["wild_mage"]) + submissions.approve_submission(made["id"]) + row = api.get("/skins/plugin/approved", headers=PLUGIN).json()["submissions"][0] + assert row["tiers"] == ["wild_mage"] + assert row["tier_sets"] == {"wild_mage": "wild mage"} + assert row["tier_aliases"] == {"wild_mage": "Wild Mage"} + + +def test_review_sheet_shows_first_frame_and_notes_animation(tmp_path): + from src.skins.review_sheet import _animated_note, _load_rgba, _single_texture_body + + png = tmp_path / "b.png" + png.write_bytes(_png((16, 48), [RED, GREEN, BLUE])) + assert _animated_note(png) == "" + (tmp_path / "b.png.mcmeta").write_bytes(_meta()) + assert _load_rgba(png).size == (16, 16) + assert _animated_note(png) == " · animated · 3 frames" + body = _single_texture_body("b", "bow_3d", None, "longbows", tmp_path) + assert body.width >= 256 diff --git a/backend/src/skins/thumbnail.py b/backend/src/skins/thumbnail.py index a821ed3a..e2d73f4e 100644 --- a/backend/src/skins/thumbnail.py +++ b/backend/src/skins/thumbnail.py @@ -8,6 +8,7 @@ import numpy as np from PIL import Image +from .animation import first_frame_png from .db import SKINS_DIR from .preview_3d import PREVIEW_NAMES @@ -28,7 +29,7 @@ def _without_backdrop(path: Path) -> bytes: def skin_thumbnail(submission_id: str, slug: str) -> bytes | None: - """A rendered preview without its backdrop, else the flat texture, else None. + """A rendered preview without its backdrop, else the flat texture (first frame), else None. Only reads files the review sheet already made; it never starts a render. """ @@ -40,5 +41,5 @@ def skin_thumbnail(submission_id: str, slug: str) -> bytes | None: for name in (f"{slug}.png", f"{slug}_unsigned.png"): path = out_dir / name if path.is_file(): - return path.read_bytes() + return first_frame_png(path) or path.read_bytes() return None diff --git a/backend/tests/test_skin_asset_lifecycle.py b/backend/tests/test_skin_asset_lifecycle.py index 754e39dd..27a075ee 100644 --- a/backend/tests/test_skin_asset_lifecycle.py +++ b/backend/tests/test_skin_asset_lifecycle.py @@ -19,6 +19,8 @@ ("helmet_3d", [""]), ("mask", [""]), ("gun", ["_carry", "_reload", "_aim", "_aim_charged"]), + ("bow_3d", ["", "_0", "_1", "_2"]), + ("crossbow_3d", ["", "_0", "_1", "_2", "_charged"]), ], ) def test_upload_writes_downloadable_models(tmp_path, monkeypatch, kind, suffixes): @@ -35,6 +37,11 @@ def test_upload_writes_downloadable_models(tmp_path, monkeypatch, kind, suffixes files.update({f"pull_{i}": png.getvalue() for i in range(3)}) elif kind == "gun": files.update({f"{pose}_model": model for pose in ("carry", "reload", "aim")}) + elif kind in ("bow_3d", "crossbow_3d"): + files["model"] = model + files.update({f"pull_{i}_model": model for i in range(3)}) + if kind == "crossbow_3d": + files["charged_model"] = model elif kind != "large_handheld": files["model"] = model folder = storage.write_submission_files( diff --git a/docs/docs/cosmetics/naming.md b/docs/docs/cosmetics/naming.md index db0e75a7..9a2d208f 100644 --- a/docs/docs/cosmetics/naming.md +++ b/docs/docs/cosmetics/naming.md @@ -40,9 +40,9 @@ Filenames are **freeform** - the API only validates PNG magic bytes, max size, a ### `armor_set` (multi-tier) -One submission holds **1-6 tiers** from the allowlist `iron | steel | abyssalite | mythril | mage | infantry`. Each tier needs all six multipart fields: `{tier}_helmet`, `{tier}_chestplate`, `{tier}_leggings`, `{tier}_boots`, `{tier}_layer_1`, `{tier}_layer_2`. +One submission is one **metal line** — up to one set per type (`light`, `medium`, `heavy`, `infantry`, `mage`) of one metal (`iron`, `steel`, `bronze`, `abyssalite`, `mythril`), tier `{type}_{metal}`, ArmourShop set `{type} {metal}` — or exactly one **single set**: `leather`, `illusion`, `spiritual`, `arcanum`, `bloodmagic`, `necromancy`, `shadowmancy`, `light_mage`, `dark_mage`, `wild_mage` (ArmourShop set: underscores become spaces, e.g. `wild mage`). Single sets cannot be mixed with other sets. Each tier needs all six multipart fields: `{tier}_helmet`, `{tier}_chestplate`, `{tier}_leggings`, `{tier}_boots`, `{tier}_layer_1`, `{tier}_layer_2`. -Plus a form field `tiers` - a JSON array, e.g. `["iron","steel"]`. Exact sizes: icons **16×16**; layers **64×32**. Every PNG in a submission must have distinct upload bytes (SHA-256). +Plus a form field `tiers` - a JSON array, e.g. `["light_iron","heavy_iron"]` or `["wild_mage"]`. Exact sizes: icons **16×16**; layers **64×32**. Every PNG in a submission must have distinct upload bytes (SHA-256). *Legacy single-tier path:* unprefixed fields plus `base_set` still accepted for backward compatibility. @@ -54,6 +54,14 @@ One `texture` field. Sizes: `handheld` **16×16**; `large_handheld` **32×32**. Fields `texture`, `pull_0`, `pull_1`, `pull_2` (bow four + `charged` for crossbow). Sizes: `bow` **16×16**; `large_bow` **32×32**. +### `bow_3d` / `crossbow_3d` + +One `texture` plus a Blockbench model per bow state: `model` (standby), `pull_0_model`, `pull_1_model`, `pull_2_model`, and `charged_model` for crossbows. Stored as `{id}.png`, `{id}.json`, `{id}_0.json`, `{id}_1.json`, `{id}_2.json`, `{id}_charged.json`, every model's textures pointing at `{id}`. All uploaded files together (texture, models, `.mcmeta`) may be at most **61440 bytes** for every rank. Missing display tabs take vanilla bow / crossbow hand poses. + +### Animated textures + +`{field}_mcmeta` beside any PNG field except armour layers; stored as `{stored png}.mcmeta` (e.g. `{id}.png.mcmeta`, `{id}_0.png.mcmeta`, `{tier}_chestplate.png.mcmeta`). A flat crossbow's `charged` animation is stored for both `{id}_charged.png` and `{id}_arrow.png`. + ### `item_3d` / `shield` / `helmet_3d` / `mask` / `gun` Multipart `texture` + model JSON(s). Combined texture+JSON pair byte budget from ArmourShop `permission-groups.yml`. Shield blocking model is **not** uploaded (ArmourShop clones at apply). diff --git a/docs/docs/cosmetics/skins.md b/docs/docs/cosmetics/skins.md index 020a8223..624bcc8c 100644 --- a/docs/docs/cosmetics/skins.md +++ b/docs/docs/cosmetics/skins.md @@ -6,7 +6,7 @@ End-to-end design for donator texture submissions on **TFMCWeb** (store + review ## Goals -- Donators submit **armor sets** (2D, optional per-tier 3D helmet), **weapon/tool skins** (`handheld` / `large_handheld` / `bow` / `large_bow` / `crossbow`), **3D kinds** (`item_3d` / `shield` / `helmet_3d` / `mask`), **guns** (`gun`), and **books** (`book`; unsigned + signed covers). +- Donators submit **armor sets** (2D, optional per-tier 3D helmet), **weapon/tool skins** (`handheld` / `large_handheld` / `bow` / `large_bow` / `crossbow`), **3D kinds** (`item_3d` / `shield` / `helmet_3d` / `mask` / `bow_3d` / `crossbow_3d`), **guns** (`gun`), and **books** (`book`; unsigned + signed covers). - Players start a skin from the Profile wardrobe ([below](#profile-wardrobe)) or with a TFMCWeb code (`/token create skin` or **`/token create skin staff`**); either is bound to the player UUID. - **Player:** staff approve/deny in Discord; ArmourShop writes **`tfmc_submissions`** + `ps_*` + LP. - **Staff curated:** auto-approve (no bot); category + scroll on upload; writes **`tfmc_armorshop`** into real ArmourShop categories. @@ -22,6 +22,8 @@ End-to-end design for donator texture submissions on **TFMCWeb** (store + review | `bow` | `texture`, `pull_0`, `pull_1`, `pull_2` | **16×16** | | | `large_bow` | same four fields | **32×32** | | | `crossbow` | bow four + `charged` | **16×16** | | +| `bow_3d` | `texture` + `model`, `pull_0_model`, `pull_1_model`, `pull_2_model` | All files together ≤ **61440 bytes**, every rank | `shortbows` / `longbows`; `BOW` + `model_path` | +| `crossbow_3d` | `bow_3d` fields + `charged_model` | Same fixed total | `crossbows`; `CROSSBOW` + `model_path` | | `item` | - | - | **Disabled** for upload | | `item_3d` | `texture` + `model` | Pair byte budget from entitlements | `generate: false` | | `shield` | `texture` + `model` | Same pair budget | Blocking clone at apply | @@ -30,6 +32,12 @@ End-to-end design for donator texture submissions on **TFMCWeb** (store + review | `gun` | `texture` + carry/reload/aim models | Three pair checks | GaG `skins.yml` `ia.…` | | `book` | `unsigned` + `signed` | Both **16×16** | `base_set: books` | +Which item types each kind may target (`base_set`) lives in [`shared/skins/base_sets.json`](../../../shared/skins/base_sets.json), read by both the API and the upload page. + +### Animated textures (`.mcmeta`) + +Any PNG field may send an optional `{field}_mcmeta` (Minecraft `.png.mcmeta` JSON, ≤ 2048 bytes), except armour layers (`{tier}_layer_1` / `{tier}_layer_2`). Only an `animation` section is accepted (`frametime`, `interpolate`, `width`, `height`, `frames`). With an `.mcmeta`, a fixed-size slot takes a whole grid of frames of that size (e.g. a 16×64 handheld strip of four frames). The API stores it beside the PNG as `{png name}.mcmeta`, lists it for the plugin, and ArmourShop copies it into the pack. Review sheets and 3D preview tiles show the first frame and label the tile `animated · N frames`. For 3D kinds the `.mcmeta` counts toward the byte budget. + Upload **filenames are ignored** for identity - the API validates PNG/JSON and writes fixed stems from the submission id. See [naming.md](naming.md). ### Grip presets (`large_handheld` only) @@ -88,7 +96,7 @@ Configured in TFMCWeb `config.yml` `player-meta.skins`; pushed on join/reload vi | defaults / no rank | disallowed | none | false | | Noble | 28 days | handheld, large_handheld, bow, large_bow, crossbow, book | false | | Gilded | 21 days | + armor_set | false | -| Ascended | 14 days | + item_3d, shield, helmet_3d, mask, gun | true | +| Ascended | 14 days | + item_3d, shield, helmet_3d, mask, gun, bow_3d, crossbow_3d | true | | Legacy | 7 days | (same as Ascended) | true | ## Discord link (MC ↔ Discord) diff --git a/docs/docs/integrations/armourshop.md b/docs/docs/integrations/armourshop.md index 5f2bbf82..52f562c1 100644 --- a/docs/docs/integrations/armourshop.md +++ b/docs/docs/integrations/armourshop.md @@ -74,6 +74,7 @@ Namespace: **`tfmc_submissions`**. | `handheld` | `generate: true` + `parent: item/handheld` | | `large_handheld` | `generate: false` + grip template model | | `bow` / `large_bow` / `crossbow` | ArmourShop writers; pull/draw frames | +| `bow_3d` / `crossbow_3d` | `generate: false` + `model_path`; web-built `{slug}.json`, `_0`, `_1`, `_2` (+ `_charged`, firework copy at apply) | | `book` | `{slug}` (`WRITABLE_BOOK`) + `{slug}_signed` (`WRITTEN_BOOK`); shop lists `{slug}` only | | `item_3d` | `generate: false` + donor JSON (`PAPER`) | | `helmet_3d` | `CARVED_PUMPKIN` + `behaviours.hat: true` (armor-set 3D helmets use the same) | @@ -81,6 +82,8 @@ Namespace: **`tfmc_submissions`**. | `shield` | Donor JSON + ArmourShop blocking clone | | `gun` | STONE_HOE carry/reload + CROSSBOW aim; GaG `skins.yml` | +A PNG may arrive with a sibling `{png name}.mcmeta` (animated texture); writers copy it next to the pack PNG. + Never add new skins via manual `custom_model_data` lists in legacy `tfmc_pack`. ## Discord link (before skins upload) diff --git a/frontend/app/components/character/LoreItemEditor.tsx b/frontend/app/components/character/LoreItemEditor.tsx index 8553ba07..79a38c4f 100644 --- a/frontend/app/components/character/LoreItemEditor.tsx +++ b/frontend/app/components/character/LoreItemEditor.tsx @@ -39,6 +39,8 @@ const KNOWN_SKIN_KINDS = new Set([ "bow", "large_bow", "crossbow", + "bow_3d", + "crossbow_3d", "item_3d", "shield", "helmet_3d", diff --git a/frontend/app/components/profile/SkinShowcase.tsx b/frontend/app/components/profile/SkinShowcase.tsx index e634ac6e..5b989935 100644 --- a/frontend/app/components/profile/SkinShowcase.tsx +++ b/frontend/app/components/profile/SkinShowcase.tsx @@ -7,7 +7,7 @@ import { kindLabel } from "../skins/KindPicker"; import { getSubmissionFile, type SubmissionPublic } from "../../../lib/skins/api"; import { armorTierLabel, baseSetLabel } from "../../../lib/skins/baseSets"; import { previewColourStopRuns } from "../../../lib/skins/namePreview"; -import { ownerFileNames } from "../../../lib/skins/ownerFiles"; +import { ownerAnimationFileNames, ownerFileNames } from "../../../lib/skins/ownerFiles"; import { ARMOR_ICON_FIELDS } from "../../../lib/skins/sizes"; import { SkinPicture } from "./WardrobePictures"; import { @@ -28,7 +28,12 @@ import { type Files = Record; const FLAT_KINDS = ["handheld", "large_handheld", "bow", "large_bow", "crossbow"]; -const ICON_LABELS: Record = { helmet: "Helmet", chestplate: "Chest", leggings: "Legs", boots: "Boots" }; +const ICON_LABELS: Record = { + helmet: "Helmet", + chestplate: "Chest", + leggings: "Legs", + boots: "Boots", +}; function statusMessage(row: SubmissionPublic): string { switch (row.status) { @@ -58,10 +63,10 @@ function useOwnerFiles(row: SubmissionPublic, sessionToken: string): Files | nul // Checking the status again returns a new row; the files only change with the skin. const { kind, slug } = row; const tiers = (row.tiers ?? []).join(","); - const names = useMemo( - () => ownerFileNames({ kind, slug, tiers: tiers ? tiers.split(",") : [] }), - [kind, slug, tiers], - ); + const names = useMemo(() => { + const row = { kind, slug, tiers: tiers ? tiers.split(",") : [] }; + return { ...ownerFileNames(row), ...ownerAnimationFileNames(row) }; + }, [kind, slug, tiers]); useEffect(() => { let live = true; setFiles(null); @@ -99,7 +104,12 @@ function Turnable({ children }: { children: ReactNode }) { ); } -type ViewProps = { row: SubmissionPublic; files: Files; fallback: ReactNode; children: ReactNode }; +type ViewProps = { + row: SubmissionPublic; + files: Files; + fallback: ReactNode; + children: ReactNode; +}; function ArmourView({ row, files, fallback, children }: ViewProps) { const tiers = row.tiers ?? []; @@ -132,13 +142,19 @@ function ArmourView({ row, files, fallback, children }: ViewProps) { {tiers.length > 1 ? ( ({ id: t, label: row.tier_aliases?.[t] || armorTierLabel(t) }))} + options={tiers.map((t) => ({ + id: t, + label: row.tier_aliases?.[t] || armorTierLabel(t), + }))} value={tier} onChange={setTier} /> ) : null} ({ label: ICON_LABELS[field]!, src: iconUrls[i] ?? null }))} + icons={ARMOR_ICON_FIELDS.map((field, i) => ({ + label: ICON_LABELS[field]!, + src: iconUrls[i] ?? null, + }))} /> } @@ -156,7 +172,14 @@ function BookView({ files, fallback, children }: Omit) { stage={ texture ? ( - + ) : ( fallback @@ -185,6 +208,8 @@ function ItemView({ row, files, fallback, children }: ViewProps) { const [failed, setFailed] = useState(false); const { kind } = row; const flat = FLAT_KINDS.includes(kind); + const ranged3d = kind === "bow_3d" || kind === "crossbow_3d"; + const mcmeta = (field: string) => files[`${field}_mcmeta`] ?? null; const grip = row.grip_preset != null ? Number(row.grip_preset) : NaN; const stage = files.texture && !failed ? ( @@ -194,10 +219,26 @@ function ItemView({ row, files, fallback, children }: ViewProps) { className="h-full p-3" kind={kind} textureFile={files.texture} - modelFile={files.model ?? null} + modelFile={ranged3d ? null : (files.model ?? null)} + textureMcmetaFile={mcmeta("texture")} + frameModels={ + ranged3d + ? { + texture: files.model ?? null, + pull_0: files.pull_0_model ?? null, + pull_1: files.pull_1_model ?? null, + pull_2: files.pull_2_model ?? null, + charged: files.charged_model ?? null, + } + : undefined + } gunModels={ kind === "gun" - ? { carry: files.carry_model ?? null, reload: files.reload_model ?? null, aim: files.aim_model ?? null } + ? { + carry: files.carry_model ?? null, + reload: files.reload_model ?? null, + aim: files.aim_model ?? null, + } : undefined } flatTextureFile={flat ? files.texture : null} @@ -212,6 +253,17 @@ function ItemView({ row, files, fallback, children }: ViewProps) { } : undefined } + flatMcmeta={ + flat + ? { + texture: mcmeta("texture"), + pull_0: mcmeta("pull_0"), + pull_1: mcmeta("pull_1"), + pull_2: mcmeta("pull_2"), + charged: mcmeta("charged"), + } + : undefined + } gripY={kind === "large_handheld" && Number.isFinite(grip) ? grip : null} onPreviewError={(message) => message && setFailed(true)} /> @@ -267,7 +319,12 @@ export default function SkinShowcase({ {row.add_name || row.name_colours?.length ? : null} void; + disabled?: boolean; + /** When set, the card offers an optional `.mcmeta` animation for this texture. */ + animation?: { + file: File | null | undefined; + onPick: (file: File) => void; + onClear: () => void; + }; +}; + +/** Ignore cancel / empty picker so an existing selection is kept. */ +function picked(list: FileList | null, apply: (file: File) => void) { + const file = list?.[0]; + if (file) apply(file); +} + +const pickButton = + "inline-flex cursor-pointer items-center rounded-sm bg-[var(--tfmc-moss)] px-3 py-1.5 text-xs font-medium text-[var(--tfmc-cream)] transition hover:opacity-90"; + +/** One upload card: required file, its hint, and an optional animation slot. */ +export default function FileSlot({ label, hint, accept, file, onPick, disabled, animation }: Props) { + const id = useId(); + const animId = useId(); + return ( +
+
+ + {label} + {hint} + + + {file ? "✓" : "Required"} + +
+
+ + { + picked(e.target.files, onPick); + e.target.value = ""; + }} + className="sr-only" + /> + {file?.name ?? ""} +
+ {animation ? ( +
+ Animation (.mcmeta) + + { + picked(e.target.files, animation.onPick); + e.target.value = ""; + }} + className="sr-only" + /> + {animation.file ? ( + <> + {animation.file.name} + + + ) : ( + optional + )} +
+ ) : null} +
+ ); +} diff --git a/frontend/app/components/skins/KindHelp.tsx b/frontend/app/components/skins/KindHelp.tsx index 42b359b1..243486c6 100644 --- a/frontend/app/components/skins/KindHelp.tsx +++ b/frontend/app/components/skins/KindHelp.tsx @@ -14,10 +14,10 @@ const GUIDES: Record = { armor_set: { title: "Armour set", summary: - "Submit one metal's skin line: up to 5 sets (Light, Medium, Heavy, Infantry and Mage) of that metal. Each set becomes its own shop listing under your name.", + "Submit one metal's skin line: up to 5 sets (Light, Medium, Heavy, Infantry and Mage) of that metal, or one single set such as Leather or a mage school. Each set becomes its own shop listing under your name.", steps: [ - "Choose the metal (Iron, Steel, Bronze, Abyssalite or Mythril). Every set in the submission uses it.", - "Add at least one set type (Light, Medium, Heavy, Infantry or Mage).", + "Choose a metal (Iron, Steel, Bronze, Abyssalite or Mythril) or a single set. Every set in the submission uses it.", + "For a metal, pick at least one set type (Light, Medium, Heavy, Infantry or Mage). You can give each weight its own look, or make only one.", "Set name is optional if you want a custom name instead of the default label (e.g. Light Iron).", "Per set, upload helmet, chestplate, leggings, and boots icons (16×16 PNG).", "Upload Layer 1 and Layer 2 body textures (64×32 PNG) for that set.", @@ -75,6 +75,30 @@ const GUIDES: Record = { "Set the item name / colours, then submit.", ], }, + bow_3d: { + title: "3D bow", + summary: "One texture and four Blockbench models, so the bow bends as it's drawn.", + steps: [ + "Choose Shortbows or Longbows.", + "Upload one texture PNG used by every model.", + "Export Standby, Pull 0, Pull 1 and Pull 2 as Java Block/Item JSON (File → Export → Export Block/Item Model; one-axis 22.5°/45° rotations only).", + "All five files together must be 60 KB or less.", + "Set the item name / colours, then submit.", + ], + notes: ["Use the frame buttons on the preview to check each draw stage in hand."], + }, + crossbow_3d: { + title: "3D crossbow", + summary: "One texture and five Blockbench models: standby, three loading stages and charged.", + steps: [ + "Choose Crossbows.", + "Upload one texture PNG used by every model.", + "Export Standby, Pull 0, Pull 1, Pull 2 and Charged as Java Block/Item JSON (one-axis 22.5°/45° rotations only).", + "All six files together must be 60 KB or less.", + "Set the item name / colours, then submit.", + ], + notes: ["The charged model is also used when a firework is loaded."], + }, item_3d: { title: "Item 3D", summary: "A custom Blockbench model used as a 3D item skin.", diff --git a/frontend/app/components/skins/KindPicker.tsx b/frontend/app/components/skins/KindPicker.tsx index 85ae0d3a..12fac9bd 100644 --- a/frontend/app/components/skins/KindPicker.tsx +++ b/frontend/app/components/skins/KindPicker.tsx @@ -4,130 +4,155 @@ import { useState } from "react"; import type { SkinKind } from "../../../lib/skins/sizes"; import { KindHelpPanel, KindHelpToggle, useKindHelpId } from "./KindHelp"; -const OPTIONS: { - value: SkinKind; - label: string; - detail: string; -}[] = [ - { value: "armor_set", label: "Armour set", detail: "One metal, up to 5 sets" }, - { value: "handheld", label: "Handheld", detail: "16×16 texture" }, - { - value: "large_handheld", +const KINDS: Record = { + armor_set: { + label: "Armour set", + style: "Armour", + detail: "Icons + body layers per set", + }, + handheld: { label: "Handheld", style: "Flat", detail: "16×16 texture" }, + large_handheld: { label: "Large handheld", - detail: "32×32", + style: "Flat (large)", + detail: "32×32 + grip slider", + }, + bow: { label: "Bow", style: "Flat frames", detail: "16×16 · 4 frames" }, + large_bow: { + label: "Large bow", + style: "Flat frames (large)", + detail: "32×32 · 4 frames", + }, + crossbow: { + label: "Crossbow", + style: "Flat frames", + detail: "16×16 · 5 frames", + }, + bow_3d: { + label: "3D bow", + style: "3D model", + detail: "1 texture + 4 models · 60 KB total", + }, + crossbow_3d: { + label: "3D crossbow", + style: "3D model", + detail: "1 texture + 5 models · 60 KB total", + }, + item_3d: { label: "Item 3D", style: "3D model", detail: "Model + texture" }, + shield: { + label: "Shield 3D", + style: "3D model", + detail: "Model + texture · idle and blocking", }, - { value: "bow", label: "Bow", detail: "16×16 · 4 frames" }, - { value: "large_bow", label: "Large bow", detail: "32×32 · 4 frames" }, - { value: "crossbow", label: "Crossbow", detail: "16×16 · 5 frames" }, - { value: "item_3d", label: "Item 3D", detail: "Model + texture" }, - { value: "shield", label: "Shield 3D", detail: "Model + texture" }, - { value: "helmet_3d", label: "Helmet 3D", detail: "Model + texture" }, - { value: "mask", label: "Mask", detail: "3D helmet · hides identity" }, - { value: "gun", label: "Gun", detail: "Carry · reload · aim" }, - { - value: "book", + helmet_3d: { + label: "Helmet 3D", + style: "3D model", + detail: "Model + texture", + }, + mask: { + label: "Mask", + style: "3D mask", + detail: "3D helmet · hides identity", + }, + gun: { label: "Gun", style: "3D model", detail: "Carry · reload · aim" }, + book: { label: "Book", + style: "Flat covers", detail: "Unsigned + signed · 16×16", }, -]; +}; /** Player-facing label for a skin kind. */ export function kindLabel(kind: string): string { - return OPTIONS.find((opt) => opt.value === kind)?.label ?? kind; + return KINDS[kind as SkinKind]?.label ?? kind; +} + +/** Short style name and file summary for a kind (e.g. "3D model", "Model + texture"). */ +export function kindStyle(kind: SkinKind): { style: string; detail: string } { + return KINDS[kind]; } type Props = { - value: SkinKind; + /** Styles the chosen item supports. */ + kinds: readonly SkinKind[]; + value: SkinKind | null; onChange: (kind: SkinKind) => void; disabled?: boolean; - /** When set, only these kinds are shown. Empty = none. Omit/undefined = all. */ - allowedKinds?: string[] | null; + /** Rank name that unlocks a kind the player can't use, or undefined when it's usable. */ + lockedBy: (kind: SkinKind) => string | undefined; }; -export default function KindPicker({ value, onChange, disabled, allowedKinds }: Props) { +/** Step 2: choose how the chosen item is skinned (flat, 3D…). */ +export default function KindPicker({ kinds, value, onChange, disabled, lockedBy }: Props) { const [helpOpen, setHelpOpen] = useState(false); const helpId = useKindHelpId(); - const options = - allowedKinds == null - ? OPTIONS - : OPTIONS.filter((opt) => allowedKinds.map((k) => k.toLowerCase()).includes(opt.value)); return (
-
- Type - setHelpOpen((v) => !v)} /> -
+ Style + {value ? ( +
+ setHelpOpen((v) => !v)} /> + How {kindLabel(value).toLowerCase()} skins work +
+ ) : null} - {options.length === 0 ? ( -

Nothing available for your rank.

- ) : ( -
- {options.map((opt) => { - const selected = value === opt.value; - return ( - - ); - })} -
- )} + /> + )} + + + ); + })} + - + {value ? : null}
); } + +export function LockIcon() { + return ( + + + + ); +} diff --git a/frontend/app/components/skins/ModelPreview.tsx b/frontend/app/components/skins/ModelPreview.tsx index 1568c2b1..820c2e19 100644 --- a/frontend/app/components/skins/ModelPreview.tsx +++ b/frontend/app/components/skins/ModelPreview.tsx @@ -14,6 +14,8 @@ import { import { buildExtrudedItemGroup, loadImageDataFromFile } from "../../../lib/skins/extrudeItem"; import { resolveFlatDisplayTab, type FlatDisplayKind } from "../../../lib/skins/flatItemDisplay"; import { resolveShieldBlockingTab } from "../../../lib/skins/shieldBlockingDisplay"; +import { loadAnimatedFrames } from "../../../lib/skins/animatedTexture"; +import { frameIndexAt, prefersReducedMotion, type ScheduledFrame } from "../../../lib/textureAnimation"; import { buildJavaModelGroup, disposeObject3D, @@ -41,6 +43,9 @@ export type FlatFrameId = "texture" | "pull_0" | "pull_1" | "pull_2" | "charged" export type FlatFrames = Partial>; +/** 3D bow / crossbow: one model per draw stage, keyed like the flat frames. */ +export type FrameModels = Partial>; + type ItemSlot = "model" | "thirdperson_righthand" | "thirdperson_lefthand" | "head"; type GunSlot = "model" | "carry_right" | "carry_left" | "reload_right" | "reload_left" | "aim_right" | "aim_left"; @@ -57,6 +62,12 @@ type Props = { /** Standby / single PNG for flat kinds (optional if flatFrames.texture set). */ flatTextureFile?: File | null; flatFrames?: FlatFrames; + /** Optional `.mcmeta` for each flat frame texture. */ + flatMcmeta?: FlatFrames; + /** Optional `.mcmeta` animating `textureFile` on a 3D model. */ + textureMcmetaFile?: File | null; + /** 3D bow / crossbow models per draw stage (all share `textureFile`). */ + frameModels?: FrameModels; /** Thirdperson grip Y for large_handheld (2.5–5.5). */ gripY?: number | null; /** Tinted potion preview color (#RRGGBB); avoids rebuilding mesh on each change. */ @@ -91,8 +102,29 @@ type ContentRefs = { steveTexture: THREE.Texture | null; potionCanvas: HTMLCanvasElement | null; floatingRoot: THREE.Group | null; + animation: PreviewAnimation | null; +}; + +/** A built animated texture: shows source frame `index` when asked. */ +type PreviewAnimation = { + schedule: ScheduledFrame[]; + show: (index: number) => void; + dispose: () => void; + current: number; + startedAt: number; }; +const EMPTY_CONTENT = (): ContentRefs => ({ + itemRoot: null, + itemTexture: null, + javaJson: null, + steveRoot: null, + steveTexture: null, + potionCanvas: null, + floatingRoot: null, + animation: null, +}); + const FRAME_CHIPS: { id: FlatFrameId; label: string }[] = [ { id: "texture", label: "Standby" }, { id: "pull_0", label: "Pull 0" }, @@ -121,8 +153,19 @@ function flatDisplayKind(kind?: DisplayKind, preset?: FlatDisplayKind): FlatDisp return preset ?? kind ?? "handheld"; } +function isRanged3dKind(kind?: DisplayKind): boolean { + return kind === "bow_3d" || kind === "crossbow_3d"; +} + function isMultiFrameKind(kind?: DisplayKind): boolean { - return kind === "bow" || kind === "large_bow" || kind === "crossbow"; + return kind === "bow" || kind === "large_bow" || kind === "crossbow" || isRanged3dKind(kind); +} + +/** Flat pose family for a 3D bow / crossbow. */ +function poseKind(kind?: DisplayKind): DisplayKind | undefined { + if (kind === "bow_3d") return "bow"; + if (kind === "crossbow_3d") return "crossbow"; + return kind; } function fileIdentity(file: File | null | undefined): string { @@ -197,6 +240,8 @@ function disposeContent(content: ContentRefs, scene?: THREE.Scene): void { content.itemTexture = null; content.javaJson = null; content.potionCanvas = null; + content.animation?.dispose(); + content.animation = null; } function itemSlotsForKind(kind?: DisplayKind): { id: ItemSlot; label: string }[] { @@ -288,12 +333,14 @@ function availableFrames( flatFrames?: FlatFrames, flatTextureFile?: File | null, textureFile?: File | null, + frameModels?: FrameModels, ): FlatFrameId[] { if (!isMultiFrameKind(kind)) return ["texture"]; const ids: FlatFrameId[] = - kind === "crossbow" + kind === "crossbow" || kind === "crossbow_3d" ? ["texture", "pull_0", "pull_1", "pull_2", "charged"] : ["texture", "pull_0", "pull_1", "pull_2"]; + if (isRanged3dKind(kind)) return ids.filter((id) => Boolean(frameModels?.[id])); return ids.filter((id) => Boolean(resolveFlatTextureFile(id, flatFrames, flatTextureFile, textureFile))); } @@ -361,6 +408,9 @@ export default function ModelPreview({ flatDisplayPreset, flatTextureFile = null, flatFrames, + flatMcmeta, + textureMcmetaFile = null, + frameModels, gripY = null, potionTintColor = null, onPreviewError, @@ -375,15 +425,7 @@ export default function ModelPreview({ const hostRef = useRef(null); const runtimeRef = useRef(null); - const contentRef = useRef({ - itemRoot: null, - itemTexture: null, - javaJson: null, - steveRoot: null, - steveTexture: null, - potionCanvas: null, - floatingRoot: null, - }); + const contentRef = useRef(EMPTY_CONTENT()); const heldItemRef = useRef(null); const heldMirrorLeftRef = useRef(false); const steveLiveRef = useRef(null); @@ -408,8 +450,8 @@ export default function ModelPreview({ showOuterLayerRef.current = showOuterLayer; const frameOptions = useMemo( - () => availableFrames(kind, flatFrames, flatTextureFile, textureFile), - [kind, flatFrames, flatTextureFile, textureFile], + () => availableFrames(kind, flatFrames, flatTextureFile, textureFile, frameModels), + [kind, flatFrames, flatTextureFile, textureFile, frameModels], ); const previewSlots = useMemo(() => { @@ -419,9 +461,13 @@ export default function ModelPreview({ const pipelineKey = `${Number(gun)}-${Number(flat)}-${kind ?? ""}-${displayKind}`; - const activeModelFile = resolveActiveModelFile(gun, slot, modelFile, gunModels); + const ranged3d = isRanged3dKind(kind); + const activeModelFile = ranged3d + ? (frameModels?.[frame] ?? null) + : resolveActiveModelFile(gun, slot, modelFile, gunModels); const activeFlatTexture = flat ? resolveFlatTextureFile(frame, flatFrames, flatTextureFile, textureFile) : null; + const activeMcmeta = flat ? (flatMcmeta?.[frame] ?? null) : textureMcmetaFile; const hasPreview = flat ? Boolean(activeFlatTexture) || potionTintMode : Boolean(activeModelFile && textureFile); @@ -431,10 +477,10 @@ export default function ModelPreview({ const placement = meshOnMannequin ? "m" : "f"; if (potionTintMode) return "potion:generated"; if (flat) { - return `flat:${fileIdentity(activeFlatTexture)}:${placement}`; + return `flat:${fileIdentity(activeFlatTexture)}:${fileIdentity(activeMcmeta)}:${placement}`; } - return `3d:${fileIdentity(activeModelFile)}:${fileIdentity(textureFile)}:${placement}`; - }, [potionTintMode, flat, activeFlatTexture, activeModelFile, textureFile, meshOnMannequin]); + return `3d:${fileIdentity(activeModelFile)}:${fileIdentity(textureFile)}:${fileIdentity(activeMcmeta)}:${placement}`; + }, [potionTintMode, flat, activeFlatTexture, activeModelFile, textureFile, activeMcmeta, meshOnMannequin]); const layoutSignature = `${slot}:${frame}:${shieldMode}:${fileIdentity(playerSkinFile)}`; @@ -482,6 +528,7 @@ export default function ModelPreview({ texture: THREE.Texture; javaJson: JavaModelJson | null; canvas: HTMLCanvasElement | null; + animation: PreviewAnimation | null; }> => { if (flat) { if (potionTintMode && tintColor) { @@ -494,11 +541,46 @@ export default function ModelPreview({ const root = buildExtrudedItemGroup(imageData, texture, { center: !onMannequin, }); - return { root, texture, javaJson: null, canvas }; + return { root, texture, javaJson: null, canvas, animation: null }; } if (!activeFlatTexture) { throw new Error("No texture for selected frame"); } + if (activeMcmeta) { + // The outline can change per frame, so each frame gets its own mesh. + const animated = await loadAnimatedFrames(activeFlatTexture, activeMcmeta); + const root = new THREE.Group(); + root.name = "animatedItem"; + const textures = animated.frames.map(textureFromCanvas); + const frameRoots = animated.frames.map((canvas, i) => { + const frameRoot = buildExtrudedItemGroup(canvasImageData(canvas), textures[i]!, { + center: !onMannequin, + }); + frameRoot.visible = false; + root.add(frameRoot); + return frameRoot; + }); + const animation: PreviewAnimation = { + schedule: animated.schedule, + current: -1, + startedAt: performance.now(), + show(index) { + frameRoots.forEach((frameRoot, i) => { + frameRoot.visible = i === index; + }); + this.current = index; + }, + dispose() {}, + }; + animation.show(animated.schedule[0]!.index); + return { + root, + texture: textures[0]!, + javaJson: null, + canvas: null, + animation, + }; + } const loaded = await loadImageDataFromFile(activeFlatTexture); const root = buildExtrudedItemGroup(loaded.imageData, loaded.texture, { center: !onMannequin, @@ -508,6 +590,7 @@ export default function ModelPreview({ texture: loaded.texture, javaJson: null, canvas: null, + animation: null, }; } if (!activeModelFile || !textureFile) { @@ -515,11 +598,49 @@ export default function ModelPreview({ } const modelText = await activeModelFile.text(); const javaJson = parseJavaModelJson(modelText); + if (activeMcmeta) { + // Model UVs span one frame, so swapping whole frame images animates it. + const animated = await loadAnimatedFrames(textureFile, activeMcmeta); + const textures = animated.frames.map((canvas) => { + const texture = new THREE.Texture(canvas); + texture.magFilter = THREE.NearestFilter; + texture.minFilter = THREE.NearestFilter; + texture.generateMipmaps = false; + texture.colorSpace = THREE.SRGBColorSpace; + texture.needsUpdate = true; + return texture; + }); + const firstIndex = animated.schedule[0]!.index; + const first = textures[firstIndex]!; + const root = buildJavaModelGroup(javaJson, first, first.image.width, first.image.height, { + center: !onMannequin, + }); + const animation: PreviewAnimation = { + schedule: animated.schedule, + current: firstIndex, + startedAt: performance.now(), + show(index) { + const texture = textures[index]; + if (texture) updateMeshTexture(root, texture); + this.current = index; + }, + dispose() { + textures.forEach((texture) => texture.dispose()); + }, + }; + return { root, texture: first, javaJson, canvas: null, animation }; + } const loaded = await loadTextureFromFile(textureFile); const root = buildJavaModelGroup(javaJson, loaded.texture, loaded.width, loaded.height, { center: !onMannequin }); - return { root, texture: loaded.texture, javaJson, canvas: null }; + return { + root, + texture: loaded.texture, + javaJson, + canvas: null, + animation: null, + }; }, - [flat, potionTintMode, activeFlatTexture, activeModelFile, textureFile], + [flat, potionTintMode, activeFlatTexture, activeModelFile, textureFile, activeMcmeta], ); const applyLayout = useCallback( @@ -565,8 +686,8 @@ export default function ModelPreview({ let pose: SteveArmPose; let chargeProgress: number | undefined; - if (flat) { - const resolved = resolveFlatArmPose(kind, frame, leftHand); + if (flat || ranged3d) { + const resolved = resolveFlatArmPose(poseKind(kind), frame, leftHand); pose = resolved.pose; chargeProgress = resolved.chargeProgress; } else if (kind === "shield" && slot !== "head") { @@ -652,7 +773,7 @@ export default function ModelPreview({ restoreOrbit(camera, controls, orbitStateRef.current); } }, - [slot, gun, flat, kind, frame, shieldMode, displayKind, playerSkinFile], + [slot, gun, flat, ranged3d, kind, frame, shieldMode, displayKind, playerSkinFile], ); // Viewer lifecycle: renderer + camera + controls persist until pipeline changes. @@ -673,13 +794,17 @@ export default function ModelPreview({ reportError(null); const transparent = stageRef.current; + const reducedMotion = prefersReducedMotion(); const scene = new THREE.Scene(); scene.background = transparent ? null : new THREE.Color(0x0f1c16); scene.add(new THREE.AmbientLight(0xffffff, 1.1)); const widthPx = host.clientWidth || 320; const heightPx = (transparent && host.clientHeight) || 240; - const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: transparent }); + const renderer = new THREE.WebGLRenderer({ + antialias: false, + alpha: transparent, + }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.setSize(widthPx, heightPx, false); renderer.outputColorSpace = THREE.SRGBColorSpace; @@ -715,6 +840,11 @@ export default function ModelPreview({ const tick = () => { if (cancelled) return; runtime.raf = requestAnimationFrame(tick); + const animation = contentRef.current.animation; + if (animation && !reducedMotion) { + const index = frameIndexAt(animation.schedule, performance.now() - animation.startedAt); + if (index !== animation.current) animation.show(index); + } controls.update(); renderer.render(scene, camera); }; @@ -728,15 +858,7 @@ export default function ModelPreview({ runtime.renderer.dispose(); runtimeRef.current = null; disposeContent(contentRef.current, runtime.scene); - contentRef.current = { - itemRoot: null, - itemTexture: null, - javaJson: null, - steveRoot: null, - steveTexture: null, - potionCanvas: null, - floatingRoot: null, - }; + contentRef.current = EMPTY_CONTENT(); heldItemRef.current = null; steveLiveRef.current = null; orbitInitializedRef.current = false; @@ -764,6 +886,7 @@ export default function ModelPreview({ if (gen !== syncGenRef.current) { disposeObject3D(built.root); built.texture.dispose(); + built.animation?.dispose(); return; } @@ -777,6 +900,7 @@ export default function ModelPreview({ steveTexture: null, potionCanvas: built.canvas, floatingRoot: null, + animation: built.animation, }; setContentVersion((v) => v + 1); reportError(null); @@ -864,7 +988,7 @@ export default function ModelPreview({ } const mannequinPreview = isMannequinSlot(slot, gun); - const showFrameChips = flat && isMultiFrameKind(kind) && frameOptions.length > 1; + const showFrameChips = (flat || ranged3d) && isMultiFrameKind(kind) && frameOptions.length > 1; const showShieldMode = kind === "shield" && mannequinPreview; return ( diff --git a/frontend/app/components/skins/SkinTargetPicker.tsx b/frontend/app/components/skins/SkinTargetPicker.tsx new file mode 100644 index 00000000..790314f4 --- /dev/null +++ b/frontend/app/components/skins/SkinTargetPicker.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useState } from "react"; +import { + ARMOR_SINGLE_SETS, + ARMOR_TYPES, + BASE_SET_ENTRIES, + BASE_SET_GROUPS, + armorTierLabel, + baseSetLabel, +} from "../../../lib/skins/baseSets"; +import { ARMOR_TARGETS, kindsForTarget, sameTarget, type SkinTarget } from "../../../lib/skins/kindAccess"; +import type { SkinKind } from "../../../lib/skins/sizes"; +import { LockIcon, kindStyle } from "./KindPicker"; + +type Tile = { + target: SkinTarget; + label: string; + detail: string; + keywords: string; +}; + +type Section = { id: string; label: string; tiles: Tile[] }; + +function styleSummary(kinds: readonly SkinKind[]): string { + const styles = kinds.map((k) => (kindStyle(k).style.startsWith("3D") ? "3D" : "Flat")); + return [...new Set(styles)].join(" · "); +} + +const ARMOR_TYPE_SUMMARY = ARMOR_TYPES.map((t) => armorTierLabel(t)).join(" · "); + +function buildSections(): Section[] { + const metals: Tile[] = ARMOR_TARGETS.filter((t) => t.type === "metal").map((target) => { + const metal = target.type === "metal" ? target.metal : ""; + return { + target, + label: baseSetLabel(metal), + detail: ARMOR_TYPE_SUMMARY, + keywords: `${metal} armour armor ${ARMOR_TYPES.join(" ")}`, + }; + }); + const singles: Tile[] = ARMOR_SINGLE_SETS.map((s) => ({ + target: { type: "single" as const, set: s.id }, + label: s.label, + detail: s.detail, + keywords: `${s.id} ${s.label} ${s.detail} armour armor`, + })); + const sections: Section[] = [ + { id: "armor_metal", label: "Armour · metal lines", tiles: metals }, + { id: "armor_single", label: "Armour · single sets", tiles: singles }, + ]; + for (const group of BASE_SET_GROUPS) { + sections.push({ + id: group.id, + label: group.label, + tiles: BASE_SET_ENTRIES.filter((row) => row.group === group.id).map((row) => ({ + target: { type: "item" as const, baseSet: row.id }, + label: row.label, + detail: styleSummary(row.kinds), + keywords: `${row.id} ${row.label} ${group.label}`, + })), + }); + } + return sections; +} + +const SECTIONS = buildSections(); + +type Props = { + value: SkinTarget | null; + onChange: (target: SkinTarget) => void; + disabled?: boolean; + /** Rank that unlocks a target the player can't skin at all, else undefined. */ + lockedBy: (kinds: readonly SkinKind[]) => string | undefined; +}; + +/** Step 1: pick the item (or armour line) the skin goes on. */ +export default function SkinTargetPicker({ value, onChange, disabled, lockedBy }: Props) { + const [query, setQuery] = useState(""); + const needle = query.trim().toLowerCase(); + const visible = SECTIONS.map((section) => ({ + ...section, + tiles: needle + ? section.tiles.filter((tile) => `${tile.keywords} ${section.label}`.toLowerCase().includes(needle)) + : section.tiles, + })).filter((section) => section.tiles.length > 0); + + return ( +
+ What are you skinning? +
+

Pick the item or armour your skin goes on.

+ +
+ + {visible.length === 0 ? ( +

Nothing matches “{query.trim()}”.

+ ) : ( + visible.map((section) => ( +
+

+ {section.label} +

+
+ {section.tiles.map((tile) => { + const selected = sameTarget(value, tile.target); + const lock = lockedBy(kindsForTarget(tile.target)); + return ( + + ); + })} +
+
+ )) + )} +
+ ); +} diff --git a/frontend/app/components/skins/UploadForm.test.tsx b/frontend/app/components/skins/UploadForm.test.tsx new file mode 100644 index 00000000..1f01cc47 --- /dev/null +++ b/frontend/app/components/skins/UploadForm.test.tsx @@ -0,0 +1,91 @@ +/** @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import UploadForm from "./UploadForm"; + +vi.mock("../../../lib/skins/api", async (importOriginal) => ({ + ...(await importOriginal()), + getCatalog: vi.fn(() => new Promise(() => undefined)), + createSubmission: vi.fn(), + checkSubmissionConflict: vi.fn(), +})); +// WebGL previews are not under test here. +vi.mock("./ModelPreview", () => ({ + default: () =>
, +})); +vi.mock("./ArmorPreview", () => ({ + default: () =>
, +})); + +afterEach(cleanup); + +function renderForm(skinKinds: string[] | null) { + return render( undefined} />); +} + +describe("UploadForm item-first flow", () => { + it("asks what is being skinned before anything else", () => { + renderForm(["handheld"]); + expect(screen.getByRole("heading", { name: /What are you skinning/ })).toBeTruthy(); + expect(screen.queryByText("Details")).toBeNull(); + expect(screen.queryByRole("button", { name: "Submit" })).toBeNull(); + }); + + it("offers a wand's flat style and shows 3D locked to Ascended", () => { + renderForm(["handheld"]); + fireEvent.click(screen.getByRole("radio", { name: /Wands/ })); + const styles = screen.getByRole("radiogroup", { name: "Skin style" }); + const flat = within(styles).getByRole("radio", { name: /Flat/ }); + const threeD = within(styles).getByRole("radio", { name: /3D model/ }); + expect(flat.getAttribute("aria-checked")).toBe("true"); + expect((threeD as HTMLButtonElement).disabled).toBe(true); + expect(threeD.textContent).toContain("Ascended"); + expect(screen.getByLabelText("Texture")).toBeTruthy(); + expect(screen.getByLabelText("Texture animation (.mcmeta)")).toBeTruthy(); + }); + + it("asks for a texture and five models for a 3D crossbow", () => { + renderForm(null); + fireEvent.change(screen.getByPlaceholderText(/Search/), { + target: { value: "cross" }, + }); + fireEvent.click(screen.getByRole("radio", { name: /Crossbows/ })); + fireEvent.click( + within(screen.getByRole("radiogroup", { name: "Skin style" })).getByRole("radio", { name: /3D model/ }), + ); + for (const label of ["Texture", "Standby model", "Pull 0 model", "Pull 1 model", "Pull 2 model", "Charged model"]) { + expect(screen.getByLabelText(label)).toBeTruthy(); + } + expect(screen.queryByLabelText("Standby model animation (.mcmeta)")).toBeNull(); + expect(screen.getByText(/All files together: 0 bytes of 60 KB/)).toBeTruthy(); + }); + + it("locks items a rank cannot skin at all", () => { + renderForm(["armor_set"]); + const tile = screen.getByRole("radio", { + name: /Battle standards/, + }) as HTMLButtonElement; + expect(tile.disabled).toBe(true); + expect(tile.textContent).toContain("Ascended"); + }); + + it("makes a single armour set one fixed set with no layer animation", () => { + renderForm(["armor_set"]); + fireEvent.click(screen.getByRole("radio", { name: /Leather/ })); + expect(screen.getByText("Leather armour", { selector: "h3" })).toBeTruthy(); + expect(screen.queryByRole("group", { name: "Set types" })).toBeNull(); + expect(screen.getByLabelText("Chestplate icon animation (.mcmeta)")).toBeTruthy(); + expect(screen.queryByLabelText("Layer 1 animation (.mcmeta)")).toBeNull(); + }); + + it("lets a metal line pick each weight", () => { + renderForm(["armor_set"]); + fireEvent.click(screen.getByRole("radio", { name: /^Steel/ })); + const types = screen.getByRole("group", { name: "Set types" }); + fireEvent.click(within(types).getByRole("button", { name: /Light Steel/ })); + fireEvent.click(within(types).getByRole("button", { name: /Heavy Steel/ })); + expect(screen.getAllByText(/^(Light|Heavy) Steel$/, { selector: "span" }).length).toBe(2); + }); +}); diff --git a/frontend/app/components/skins/UploadForm.tsx b/frontend/app/components/skins/UploadForm.tsx index e19f7ff5..95160850 100644 --- a/frontend/app/components/skins/UploadForm.tsx +++ b/frontend/app/components/skins/UploadForm.tsx @@ -2,38 +2,54 @@ import LoadingSkeleton from "@/app/components/shared/LoadingSkeleton"; -import { FormEvent, useEffect, useState } from "react"; +import { FormEvent, useEffect, useState, type ReactNode } from "react"; import { createSubmission, checkSubmissionConflict, getCatalog, SkinsApiError } from "../../../lib/skins/api"; import type { SkinsCatalog } from "../../../lib/skins/catalog"; import { filterStaffCategories } from "../../../lib/skins/catalog"; import { DEV_CATALOG_ENTITLEMENTS, DEV_SESSION_SKIN_KINDS } from "../../../lib/skins/entitlementsDev"; import { setLastSubmissionId } from "../../../lib/skins/session"; import { + ARMOR_SINGLE_SETS, ARMOR_TYPES, + RANGED_3D_TOTAL_BYTES, armorTier, armorTierLabel, baseSetLabel, - baseSetPickerTitle, - baseSetsForKind, - defaultBaseSet, + kindsForBaseSet, } from "../../../lib/skins/baseSets"; +import { + firstAllowedKind, + kindAllowed, + kindsForTarget, + rankForKind, + sameTarget, + type SkinTarget, +} from "../../../lib/skins/kindAccess"; import { assert3dPairBudgets, - assertFileSize, + assertRanged3dBudget, + assertTextureFile, expectedSizeForField, fileFieldsForKind, - isBowFrameKind, + formatBytes, isBookKind, + isGunKind, isLargeTextureKind, isModel3dKind, - isGunKind, + isModelField, + isRanged3dKind, + mcmetaAllowed, + mcmetaField, pairBudgetHint, + ranged3dTotalBytes, ARMOR_BODY_FIELDS, type SkinKind, } from "../../../lib/skins/sizes"; import { NAME_STYLES, normalizePreviewHex, type NameStyle } from "../../../lib/skins/namePreview"; import FancyCheckbox from "./FancyCheckbox"; +import FileSlot from "./FileSlot"; import KindPicker from "./KindPicker"; +import SkinTargetPicker from "./SkinTargetPicker"; import ModelPreview from "./ModelPreview"; import ArmorPreview from "./ArmorPreview"; import NameColourPicker from "../shared/NameColourPicker"; @@ -49,25 +65,6 @@ function isFlatPreviewKind(kind: SkinKind): boolean { ); } -function resolveModelPreviewFiles( - kind: SkinKind, - files: Record, -): { model: File | null; texture: File | null } { - if (isModel3dKind(kind)) { - return { - model: files.model ?? null, - texture: files.texture ?? null, - }; - } - if (isGunKind(kind) || isFlatPreviewKind(kind)) { - return { - model: null, - texture: files.texture ?? null, - }; - } - return { model: null, texture: null }; -} - type Props = { sessionToken: string; /** Staff curated lane — category/scroll required. */ @@ -84,7 +81,7 @@ type Props = { }; type TierEntry = { - /** Armour type (light, medium, …); the tier id adds the chosen metal. */ + /** Armour type (light, medium, …) of the metal line, or the single set id. */ type: string; /** Display suffix for this set; empty means the default (Light Iron, …). */ alias: string; @@ -96,57 +93,71 @@ type TierEntry = { }; const fieldLabel: Record = { - helmet: "Helmet (16×16)", - helmet_texture: "Helmet texture (PNG)", - helmet_model: "Helmet model (JSON)", - chestplate: "Chestplate (16×16)", - leggings: "Leggings (16×16)", - boots: "Boots (16×16)", - layer_1: "Layer 1 (64×32)", - layer_2: "Layer 2 (64×32)", - texture: "Texture (PNG)", - model: "Model (JSON)", - carry_model: "Carry model (JSON)", - reload_model: "Reload model (JSON)", - aim_model: "Aim model (JSON)", + helmet: "Helmet icon", + helmet_texture: "Helmet texture", + helmet_model: "Helmet model", + chestplate: "Chestplate icon", + leggings: "Leggings icon", + boots: "Boots icon", + layer_1: "Layer 1", + layer_2: "Layer 2", + texture: "Texture", + model: "Model", + carry_model: "Carry model", + reload_model: "Reload model", + aim_model: "Aim model", pull_0: "Pull 0", pull_1: "Pull 1", pull_2: "Pull 2", charged: "Charged", - unsigned: "Unsigned cover (16×16)", - signed: "Signed cover (16×16)", + pull_0_model: "Pull 0 model", + pull_1_model: "Pull 1 model", + pull_2_model: "Pull 2 model", + charged_model: "Charged model", + unsigned: "Unsigned cover", + signed: "Signed cover", }; function slotLabel(kind: SkinKind, field: string): string { - if (kind === "armor_set") { - return fieldLabel[field] || field; - } - if (isModel3dKind(kind) || isGunKind(kind) || isBookKind(kind)) { - return fieldLabel[field] || field; + if (field === "texture" && (kind === "bow" || kind === "large_bow" || kind === "crossbow")) { + return "Standby"; } - const size = isLargeTextureKind(kind) ? "32×32" : "16×16"; - const base = fieldLabel[field] || field; - if (field === "texture") { - return isBowFrameKind(kind) ? `Standby texture (${size})` : `Texture (${size})`; + if (field === "model" && isRanged3dKind(kind)) { + return "Standby model"; } - if (isBowFrameKind(kind)) { - return `${base} (${size})`; - } - return `Texture (${size})`; + return fieldLabel[field] || field; +} + +function slotHint(kind: SkinKind, field: string): string { + if (isModelField(field)) return "Java Block/Item JSON"; + const size = expectedSizeForField(kind, field); + if (!size) return isRanged3dKind(kind) ? "PNG, any size · shared by every model" : "PNG, any size"; + return `${size.w}×${size.h} PNG`; } function acceptForField(field: string): string { - if ( - field === "model" || - field.endsWith("_model") || - field === "helmet_model" || - field === "carry_model" || - field === "reload_model" || - field === "aim_model" - ) { - return "application/json,.json"; - } - return "image/png,.png"; + return isModelField(field) ? "application/json,.json" : "image/png,.png"; +} + +function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) { + return ( +
+

+ + {n} + + {title} +

+ {children} +
+ ); +} + +function newTier(type: string): TierEntry { + return { type, alias: "", helmet3d: false, scroll: "", files: {} }; } export default function UploadForm({ @@ -160,18 +171,10 @@ export default function UploadForm({ onSubmitted, }: Props) { const resolvedKinds = skinKinds != null ? skinKinds : staff ? null : DEV_SESSION_SKIN_KINDS; - const initialKind = ( - resolvedKinds == null - ? "armor_set" - : resolvedKinds.includes("armor_set") - ? "armor_set" - : resolvedKinds[0] || "handheld" - ) as SkinKind; - const [kind, setKind] = useState(initialKind); - const [baseSet, setBaseSet] = useState(defaultBaseSet(initialKind)); + const [target, setTarget] = useState(null); + const [kind, setKind] = useState(null); const canUseArmor3dHelmet = staff || allowArmor3dHelmet; const [tiers, setTiers] = useState([]); - const [tierToAdd, setTierToAdd] = useState(""); /** Which added set (by type) the armor mannequin preview shows. */ const [previewArmorTier, setPreviewArmorTier] = useState(""); const [itemName, setItemName] = useState(""); @@ -191,16 +194,6 @@ export default function UploadForm({ signed: string | null; }>({ unsigned: null, signed: null }); - useEffect(() => { - setTiers([]); - setTierToAdd(""); - setError(null); - setBaseSet(defaultBaseSet(kind)); - setFiles({}); - setCategory(""); - setScroll(""); - }, [kind]); - useEffect(() => { if (canUseArmor3dHelmet) return; setTiers((prev) => { @@ -210,6 +203,7 @@ export default function UploadForm({ const files = { ...t.files }; delete files.helmet_model; delete files.helmet_texture; + delete files[mcmetaField("helmet_texture")]; return { ...t, helmet3d: false, files }; }); }); @@ -245,6 +239,7 @@ export default function UploadForm({ }, []); const catalogDefaults = catalog?.entitlements?.defaults; + const entitlements = catalog?.entitlements?.groups?.length ? catalog.entitlements : DEV_CATALOG_ENTITLEMENTS; const resolvedColourStops = nameColourStops !== undefined ? nameColourStops : (catalogDefaults?.name_colour_stops ?? 0); const resolvedPairBytes = @@ -257,27 +252,33 @@ export default function UploadForm({ : 0; const pairHint = pairBudgetHint(resolvedPairBytes); - const fileFields = fileFieldsForKind(kind); - const baseOptions = baseSetsForKind(kind); + /** Rank that unlocks a kind, or undefined when the player can already use it. */ + const lockedByKind = (k: SkinKind): string | undefined => + kindAllowed(k, resolvedKinds) ? undefined : (rankForKind(entitlements, k) ?? "Locked"); + const lockedByKinds = (kinds: readonly SkinKind[]): string | undefined => + kinds.length === 0 || kinds.some((k) => kindAllowed(k, resolvedKinds)) ? undefined : lockedByKind(kinds[0]!); + const isArmor = kind === "armor_set"; - const staffCategories = catalog ? filterStaffCategories(catalog.categories, kind) : []; + const isSingleSet = target?.type === "single"; + const metal = target?.type === "metal" ? target.metal : ""; + const lineLabel = + target?.type === "metal" + ? baseSetLabel(target.metal) + : target?.type === "single" + ? (ARMOR_SINGLE_SETS.find((s) => s.id === target.set)?.label ?? target.set) + : ""; + const baseSet = target?.type === "item" ? target.baseSet : ""; + const fileFields = kind ? fileFieldsForKind(kind) : []; + const staffCategories = catalog && kind ? filterStaffCategories(catalog.categories, kind) : []; const staffScrolls = catalog?.scrolls ?? []; - const previewFiles = resolveModelPreviewFiles(kind, files); - /** Armour's base-set picker is the metal; every set in the line uses it. */ - const tierId = (entry: TierEntry) => armorTier(entry.type, baseSet); + const tierId = (entry: TierEntry) => (isSingleSet ? entry.type : armorTier(entry.type, metal)); const tierLabel = (entry: TierEntry) => armorTierLabel(tierId(entry)); - const remainingTiers: string[] = ARMOR_TYPES.filter((t) => !tiers.some((entry) => entry.type === t)); - const effectiveTierToAdd = remainingTiers.includes(tierToAdd) ? tierToAdd : (remainingTiers[0] ?? ""); const previewTierEntry = tiers.find((e) => e.type === previewArmorTier) ?? tiers[0] ?? null; useEffect(() => { - if (!isArmor) { - setPreviewArmorTier(""); - return; - } - if (tiers.length === 0) { + if (!isArmor || tiers.length === 0) { setPreviewArmorTier(""); return; } @@ -286,71 +287,80 @@ export default function UploadForm({ } }, [isArmor, tiers, previewArmorTier]); - function setFile(field: string, file: File | null) { - setFiles((prev) => ({ ...prev, [field]: file })); + function resetUploads() { + setFiles({}); + setError(null); + setCategory(""); + setScroll(""); } - /** Ignore cancel / empty picker so an existing selection is kept. */ - function onPickedFile(fileList: FileList | null, apply: (file: File) => void) { - const file = fileList?.[0]; - if (file) apply(file); + function selectTarget(next: SkinTarget) { + if (sameTarget(next, target)) return; + setTarget(next); + setKind(firstAllowedKind(next, resolvedKinds)); + setTiers(next.type === "single" ? [newTier(next.set)] : []); + resetUploads(); } - function addTier(type: string) { - if (!type || tiers.some((entry) => entry.type === type)) return; - if (tiers.length >= MAX_SETS) return; - setTiers((prev) => [ - ...prev, - { - type, - alias: "", - helmet3d: false, - scroll: "", - files: {}, - }, - ]); - setTierToAdd(""); - setError(null); + function selectKind(next: SkinKind) { + if (next === kind) return; + setKind(next); + resetUploads(); } - function removeTier(type: string) { - setTiers((prev) => prev.filter((entry) => entry.type !== type)); + function setFile(field: string, file: File | null) { + setFiles((prev) => ({ ...prev, [field]: file })); } - function setTierAlias(type: string, alias: string) { - setTiers((prev) => prev.map((entry) => (entry.type === type ? { ...entry, alias } : entry))); + function toggleType(type: string) { + setError(null); + setTiers((prev) => { + if (prev.some((entry) => entry.type === type)) return prev.filter((entry) => entry.type !== type); + if (prev.length >= MAX_SETS) return prev; + // Keep sets in Light → Mage order. + return [...prev, newTier(type)].sort((a, b) => ARMOR_TYPES.indexOf(a.type) - ARMOR_TYPES.indexOf(b.type)); + }); } - function setTierScroll(type: string, scrollId: string) { - setTiers((prev) => prev.map((entry) => (entry.type === type ? { ...entry, scroll: scrollId } : entry))); + function updateTier(type: string, change: (entry: TierEntry) => TierEntry) { + setTiers((prev) => prev.map((entry) => (entry.type === type ? change(entry) : entry))); } function setTierHelmet3d(type: string, helmet3d: boolean) { - setTiers((prev) => - prev.map((entry) => { - if (entry.type !== type) return entry; - const files = { ...entry.files }; - if (helmet3d) { - delete files.helmet; - } else { - delete files.helmet_model; - delete files.helmet_texture; - } - return { ...entry, helmet3d, files }; - }), - ); + updateTier(type, (entry) => { + const files = { ...entry.files }; + if (helmet3d) { + delete files.helmet; + delete files[mcmetaField("helmet")]; + } else { + delete files.helmet_model; + delete files.helmet_texture; + delete files[mcmetaField("helmet_texture")]; + } + return { ...entry, helmet3d, files }; + }); } function setTierFile(type: string, field: string, file: File | null) { - setTiers((prev) => - prev.map((entry) => (entry.type === type ? { ...entry, files: { ...entry.files, [field]: file } } : entry)), - ); + updateTier(type, (entry) => ({ + ...entry, + files: { ...entry.files, [field]: file }, + })); } async function onSubmit(e: FormEvent) { e.preventDefault(); setError(null); + if (!target || !kind) { + setError(target ? "Choose a style" : "Choose what you're skinning"); + return; + } + if (!kindAllowed(kind, resolvedKinds)) { + setError(`That style unlocks at ${lockedByKind(kind)}.`); + return; + } + const name = itemName.trim(); if (!name) { setError("Enter an item name."); @@ -380,15 +390,13 @@ export default function UploadForm({ return; } } - } - - if (!baseSet || !baseOptions.includes(baseSet)) { - setError(`Choose a ${baseSetPickerTitle(kind).toLowerCase()}`); + } else if (!baseSet || !kindsForBaseSet(baseSet).includes(kind)) { + setError("Choose an item type"); return; } if (isArmor && tiers.length < 1) { - setError("Add at least 1 armour set"); + setError("Pick at least 1 armour set"); return; } @@ -426,55 +434,51 @@ export default function UploadForm({ const uploadFiles: Record = {}; - if (isArmor) { - try { + /** Check one slot and add it (and its optional animation) to the upload. */ + async function addSlot( + key: string, + field: string, + file: File | null | undefined, + mcmeta: File | null | undefined, + label: string, + ) { + if (!file) throw new Error(`Missing ${label}`); + if (isModelField(field)) { + await assertVanillaJavaBlockModelFile(file); + } else { + const animation = mcmetaAllowed(field) ? mcmeta : null; + await assertTextureFile(file, expectedSizeForField(kind!, field), animation, label); + if (animation) uploadFiles[mcmetaField(key)] = animation; + } + uploadFiles[key] = file; + } + + try { + if (isArmor) { for (const entry of tiers) { - const setLabel = tierLabel(entry); const helmetFields = entry.helmet3d ? (["helmet_model", "helmet_texture"] as const) : (["helmet"] as const); - const fields = [...helmetFields, ...ARMOR_BODY_FIELDS]; - for (const field of fields) { - const file = entry.files[field]; - const label = fieldLabel[field] || field; - if (!file) { - throw new Error(`Missing ${label} for the ${setLabel} set`); - } - const expected = expectedSizeForField(kind, field); - if (expected) { - await assertFileSize(file, expected, `${setLabel} ${label}`); - } - if (field === "helmet_model") { - await assertVanillaJavaBlockModelFile(file); - } - uploadFiles[`${tierId(entry)}_${field}`] = file; + for (const field of [...helmetFields, ...ARMOR_BODY_FIELDS]) { + await addSlot( + `${tierId(entry)}_${field}`, + field, + entry.files[field], + entry.files[mcmetaField(field)], + `${tierLabel(entry)} ${fieldLabel[field] || field}`, + ); } } const h3d = tiers.filter((t) => t.helmet3d).map(tierId); assert3dPairBudgets(kind, uploadFiles, resolvedPairBytes, h3d); - } catch (err) { - setError(err instanceof Error ? err.message : "File validation failed"); - return; - } - } else { - try { + } else { for (const field of fileFields) { - const file = files[field]; - if (!file) { - throw new Error(`Missing file: ${fieldLabel[field] || field}`); - } - const expected = expectedSizeForField(kind, field); - if (expected) { - await assertFileSize(file, expected, fieldLabel[field] || field); - } - if (field === "model" || field.endsWith("_model")) { - await assertVanillaJavaBlockModelFile(file); - } - uploadFiles[field] = file; + await addSlot(field, field, files[field], files[mcmetaField(field)], slotLabel(kind, field)); } assert3dPairBudgets(kind, uploadFiles, resolvedPairBytes); - } catch (err) { - setError(err instanceof Error ? err.message : "File validation failed"); - return; + assertRanged3dBudget(kind, uploadFiles); } + } catch (err) { + setError(err instanceof Error ? err.message : "File validation failed"); + return; } if (colours.length > 0) { @@ -539,6 +543,40 @@ export default function UploadForm({ setStyles((prev) => (prev.includes(style) ? prev.filter((s) => s !== style) : [...prev, style])); } + /** Upload card for one field; textures get an optional animation slot. */ + function slot( + field: string, + label: string, + picked: Record, + pick: (field: string, file: File | null) => void, + ) { + return ( + pick(field, file)} + animation={ + mcmetaAllowed(field) + ? { + file: picked[mcmetaField(field)], + onPick: (file) => pick(mcmetaField(field), file), + onClear: () => pick(mcmetaField(field), null), + } + : undefined + } + /> + ); + } + + const ranged3d = kind ? isRanged3dKind(kind) : false; + const rangedTotal = ranged3d ? ranged3dTotalBytes(files) : 0; + const styleKinds = target?.type === "item" ? kindsForTarget(target) : []; + const detailsStep = target?.type === "item" ? 3 : 2; + return (
{loading ? ( @@ -555,458 +593,486 @@ export default function UploadForm({
) : null}
- + + + - {pairHint && (isModel3dKind(kind) || isGunKind(kind) || kind === "armor_set") ? ( -

{pairHint}

+ {target?.type === "item" ? ( + + + ) : null} - {staff ? ( -
- Shop landing - {catalogError ? ( -

- {catalogError} -

- ) : !catalog ? ( - - ) : ( - <> - - {!isArmor ? ( - + {kind ? ( + + {staff ? ( +
+ Shop landing + {catalogError ? ( +

+ {catalogError} +

+ ) : !catalog ? ( + ) : ( -

Choose a scroll on each armour set below.

+ <> + + {!isArmor ? ( + + ) : ( +

Choose a scroll on each armour set below.

+ )} + )} - - )} -
- ) : null} - - +
+ ) : null} - + }) ? ( + + {displayNameError(itemName, { + minLen: 1, + maxLen: 24, + field: "item name", + })} + + ) : null} + - + -
- Name colours and styles +
+ Name colours and styles - + -
- Styles -
- {NAME_STYLES.map((s) => ( -
+
+ + {kind === "large_handheld" ? ( +
+ Grip height + - ))} -
-
- - - {kind === "large_handheld" ? ( -
- Grip height - -
+ + ) : null} + ) : null} - {isArmor ? ( -
- Armour sets -

- Add up to {MAX_SETS} sets, one of each type, all in {baseSetLabel(baseSet)}. Each needs a helmet, - chestplate, leggings, boots and both layers. + {kind ? ( + +

+ Any texture except armour layers can be animated: add its Minecraft .mcmeta and stack the + frames in the PNG + {isLargeTextureKind(kind) ? " (each frame 32×32)" : ""}.

+ {pairHint && (isModel3dKind(kind) || isGunKind(kind) || isArmor) ? ( +

{pairHint}

+ ) : null} + {ranged3d ? ( +

RANGED_3D_TOTAL_BYTES ? "text-[#e8a0a0]" : "text-[var(--tfmc-mist)]"}`} + aria-live="polite" + > + All files together: {formatBytes(rangedTotal)} of {formatBytes(RANGED_3D_TOTAL_BYTES)} +

+ ) : null} - {tiers.length > 0 ? ( -
- {tiers.map((entry) => { - const helmetFields = entry.helmet3d - ? (["helmet_model", "helmet_texture"] as const) - : (["helmet"] as const); - const tierFields = [...helmetFields, ...ARMOR_BODY_FIELDS]; - return ( -
-
- {tierLabel(entry)} - -
- - {staff ? ( - - ) : null} - {canUseArmor3dHelmet ? ( -
- ) : ( -

No sets added yet.

- )} - - {remainingTiers.length > 0 ? ( -
- - -
+ ) : ( +

No sets picked yet.

+ )} +
) : ( -

All {MAX_SETS} sets added.

- )} - - ) : ( -
- {fileFields.map((field) => ( - - ))} - {isBookKind(kind) ? (
-
- {( - [ - ["unsigned", "Unsigned", bookPreviewUrls.unsigned], - ["signed", "Signed", bookPreviewUrls.signed], - ] as const - ).map(([key, label, url]) => ( -
- {label} -
- {url ? ( - // eslint-disable-next-line @next/next/no-img-element - {`${label} - ) : ( - No file - )} -
-
- ))} +
+ {fileFields.map((field) => slot(field, slotLabel(kind, field), files, setFile))}
-
- {files.unsigned ? ( -
-

- Unsigned 3D -

- + {isBookKind(kind) ? ( +
+
+ {( + [ + ["unsigned", "Unsigned", bookPreviewUrls.unsigned], + ["signed", "Signed", bookPreviewUrls.signed], + ] as const + ).map(([key, label, url]) => ( +
+ {label} +
+ {url ? ( + // eslint-disable-next-line @next/next/no-img-element + {`${label} + ) : ( + No file + )} +
+
+ ))}
- ) : null} - {files.signed ? ( -
-

- Signed 3D -

- +
+ {(["unsigned", "signed"] as const).map((cover) => + files[cover] ? ( +
+

+ {cover === "unsigned" ? "Unsigned" : "Signed"} 3D +

+ +
+ ) : null, + )}
- ) : null} -
+
+ ) : null} + {isModel3dKind(kind) || isGunKind(kind) || isFlatPreviewKind(kind) || ranged3d ? ( + + ) : null}
- ) : null} - {isModel3dKind(kind) || isGunKind(kind) || isFlatPreviewKind(kind) ? ( - - ) : null} -
- )} + )} + + ) : target && !kind ? ( +

+ {lockedByKinds(kindsForTarget(target)) + ? `Skins for this unlock at ${lockedByKinds(kindsForTarget(target))}.` + : "Choose a style to continue."} +

+ ) : null} {error ? (

@@ -1014,19 +1080,23 @@ export default function UploadForm({

) : null} -

- {staff - ? "Staff uploads auto-approve and land in the curated shop pack (no Discord review)." - : "You'll get a Discord DM within 5 minutes confirming it's in review."} -

+ {kind ? ( + <> +

+ {staff + ? "Staff uploads auto-approve and land in the curated shop pack (no Discord review)." + : "You'll get a Discord DM within 5 minutes confirming it's in review."} +

- + + + ) : null}
); diff --git a/frontend/app/components/wiki/stationTextureAnimation.ts b/frontend/app/components/wiki/stationTextureAnimation.ts index 90e50650..85692cbc 100644 --- a/frontend/app/components/wiki/stationTextureAnimation.ts +++ b/frontend/app/components/wiki/stationTextureAnimation.ts @@ -1,59 +1,9 @@ -export type TextureAnimationMetadata = { - animation: { - frametime?: number; - width?: number; - height?: number; - frames?: Array; - }; -}; +import { createTextureAnimation } from "../../../lib/textureAnimation"; -/** Minecraft animation times are game ticks (50 ms), not browser frames. */ -export function createStationTextureAnimation( - imageWidth: number, - imageHeight: number, - metadata: TextureAnimationMetadata, -) { - const animation = metadata.animation; - const defaultSize = Math.min(imageWidth, imageHeight); - const frameWidth = animation.width ?? defaultSize; - const frameHeight = animation.height ?? defaultSize; - const columns = imageWidth / frameWidth; - const rows = imageHeight / frameHeight; - if (!Number.isInteger(columns) || !Number.isInteger(rows) || columns < 1 || rows < 1) { - throw new Error("Invalid station animation frame dimensions."); - } - const frameTime = animation.frametime ?? 1; - const frames = (animation.frames ?? Array.from({ length: columns * rows }, (_, i) => i)).map((frame) => ({ - index: typeof frame === "number" ? frame : frame.index, - duration: (typeof frame === "number" ? frameTime : (frame.time ?? frameTime)) * 50, - })); - if ( - !frames.length || - frames.some( - ({ index, duration }) => - !Number.isInteger(index) || index < 0 || index >= columns * rows || !Number.isFinite(duration) || duration <= 0, - ) - ) { - throw new Error("Invalid station animation frames."); - } - const duration = frames.reduce((total, frame) => total + frame.duration, 0); +export type { TextureAnimationMetadata } from "../../../lib/textureAnimation"; - return (elapsedMs: number) => { - let time = Math.max(0, elapsedMs) % duration; - const frame = frames.find((candidate) => { - if (time < candidate.duration) return true; - time -= candidate.duration; - return false; - })!; - return { - repeatX: 1 / columns, - repeatY: 1 / rows, - offsetX: (frame.index % columns) / columns, - // TextureLoader flips image Y: frame zero is at the top of the PNG. - offsetY: 1 - (Math.floor(frame.index / columns) + 1) / rows, - }; - }; -} +/** Minecraft animation times are game ticks (50 ms), not browser frames. */ +export const createStationTextureAnimation = createTextureAnimation; /** Keep an atlas on its first frame when optional animation metadata is unavailable. */ export function createStationTextureAtlasFallback( @@ -73,5 +23,10 @@ export function createStationTextureAtlasFallback( } const rows = imageHeight / frameHeight; - return () => ({ repeatX: 1, repeatY: 1 / rows, offsetX: 0, offsetY: 1 - 1 / rows }); + return () => ({ + repeatX: 1, + repeatY: 1 / rows, + offsetX: 0, + offsetY: 1 - 1 / rows, + }); } diff --git a/frontend/lib/skins/animatedTexture.ts b/frontend/lib/skins/animatedTexture.ts new file mode 100644 index 00000000..de2c8c75 --- /dev/null +++ b/frontend/lib/skins/animatedTexture.ts @@ -0,0 +1,59 @@ +import { + frameGrid, + frameRect, + frameSchedule, + readTextureAnimationFile, + type ScheduledFrame, +} from "../textureAnimation"; + +/** An animated PNG cut into one canvas per source frame, plus its play order. */ +export type AnimatedFrames = { + frames: HTMLCanvasElement[]; + schedule: ScheduledFrame[]; +}; + +async function loadImage(file: File): Promise { + const url = URL.createObjectURL(file); + try { + return await new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error("Could not load texture PNG")); + img.src = url; + }); + } finally { + URL.revokeObjectURL(url); + } +} + +/** + * Cut `texture` into its animation frames using `mcmeta`. Frames are separate canvases, + * so a preview can swap whole images instead of shifting UVs (flat items need a + * separate mesh per frame because the outline can change). + */ +export async function loadAnimatedFrames(texture: File, mcmeta: File): Promise { + const metadata = await readTextureAnimationFile(mcmeta); + const image = await loadImage(texture); + const width = image.naturalWidth || image.width; + const height = image.naturalHeight || image.height; + const grid = frameGrid(width, height, metadata.animation); + const schedule = frameSchedule(metadata.animation, grid.count); + const frames: HTMLCanvasElement[] = []; + for (let i = 0; i < grid.count; i++) { + const rect = frameRect(i, grid); + const canvas = document.createElement("canvas"); + canvas.width = rect.w; + canvas.height = rect.h; + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + if (!ctx) throw new Error("Could not read texture PNG"); + ctx.drawImage(image, rect.x, rect.y, rect.w, rect.h, 0, 0, rect.w, rect.h); + frames.push(canvas); + } + return { frames, schedule }; +} + +export function canvasImageData(canvas: HTMLCanvasElement): ImageData { + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + if (!ctx) throw new Error("Could not read texture canvas"); + return ctx.getImageData(0, 0, canvas.width, canvas.height); +} diff --git a/frontend/lib/skins/baseSets.test.ts b/frontend/lib/skins/baseSets.test.ts index 324e9847..1ae63c2b 100644 --- a/frontend/lib/skins/baseSets.test.ts +++ b/frontend/lib/skins/baseSets.test.ts @@ -1,16 +1,48 @@ import { describe, expect, it } from "vitest"; -import { ARMOR_METALS, ARMOR_TYPES, armorTier, armorTierLabel, baseSetLabel, baseSetsForKind } from "./baseSets"; +import catalog from "../../../shared/skins/base_sets.json"; +import { + ARMOR_METALS, + ARMOR_SINGLE_SETS, + ARMOR_TYPES, + BASE_SET_ENTRIES, + BASE_SET_GROUPS, + RANGED_3D_TOTAL_BYTES, + armorTier, + armorTierLabel, + baseSetLabel, + baseSetsForKind, + kindsForBaseSet, +} from "./baseSets"; -describe("lute base set", () => { - it("offers Lutes for 16×16 handheld and item 3D", () => { - expect(baseSetsForKind("handheld")).toContain("lutes"); - expect(baseSetsForKind("item_3d")).toContain("lutes"); - expect(baseSetLabel("lutes")).toBe("Lutes"); +describe("base sets from shared/skins/base_sets.json", () => { + it("lists every base set of the shared file once", () => { + expect(BASE_SET_ENTRIES.map((row) => row.id)).toEqual(catalog.base_sets.map((row) => row.id)); + const groups = new Set(BASE_SET_GROUPS.map((g) => g.id)); + expect(BASE_SET_ENTRIES.every((row) => groups.has(row.group))).toBe(true); }); - it("keeps Lutes off large handheld", () => { + it("offers wands and mage blades flat and in 3D", () => { + expect(baseSetsForKind("handheld")).toEqual( + expect.arrayContaining(["wands", "mage blades", "pickaxes", "shovels"]), + ); + expect(baseSetsForKind("item_3d")).toEqual(expect.arrayContaining(["wands", "mage blades", "lutes"])); + expect(baseSetLabel("mage blades")).toBe("Mage blades"); + }); + + it("puts longswords and greataxes on 32×32 and keeps lutes off it", () => { + expect(baseSetsForKind("large_handheld")).toEqual(expect.arrayContaining(["longswords", "greataxes"])); expect(baseSetsForKind("large_handheld")).not.toContain("lutes"); }); + + it("keeps battle standards 3D only", () => { + expect(kindsForBaseSet("battle standards")).toEqual(["item_3d"]); + }); + + it("gives every bow a 3D style", () => { + expect(baseSetsForKind("bow_3d")).toEqual(["shortbows", "longbows"]); + expect(baseSetsForKind("crossbow_3d")).toEqual(["crossbows"]); + expect(RANGED_3D_TOTAL_BYTES).toBe(61440); + }); }); describe("armour metal lines", () => { @@ -19,12 +51,18 @@ describe("armour metal lines", () => { expect(armorTierLabel("heavy_abyssalite")).toBe("Heavy Abyssalite"); }); - it("still labels older bare tiers", () => { + it("still labels older bare tiers and single sets", () => { expect(armorTierLabel("mage")).toBe("Mage"); + expect(armorTierLabel("wild_mage")).toBe("Wild Mage"); }); it("offers one metal per submission and five set types", () => { expect(baseSetsForKind("armor_set")).toEqual(ARMOR_METALS); expect(ARMOR_TYPES).toHaveLength(5); }); + + it("offers leather and the mage schools as single sets", () => { + expect(ARMOR_SINGLE_SETS.map((s) => s.id)).toEqual(expect.arrayContaining(["leather", "illusion", "shadowmancy"])); + expect(ARMOR_SINGLE_SETS.map((s) => s.id)).not.toContain("exploration"); + }); }); diff --git a/frontend/lib/skins/baseSets.ts b/frontend/lib/skins/baseSets.ts index ac46614b..6970ff50 100644 --- a/frontend/lib/skins/baseSets.ts +++ b/frontend/lib/skins/baseSets.ts @@ -1,14 +1,48 @@ +import catalog from "../../../shared/skins/base_sets.json"; import type { SkinKind } from "./sizes"; +/** + * What a player skin can apply to, from shared/skins/base_sets.json (the backend reads + * the same file). Each base set id is the ArmourShop base-sets.yml key. + */ + +export type BaseSetGroup = { id: string; label: string }; + +export type BaseSetEntry = { + id: string; + label: string; + group: string; + kinds: readonly SkinKind[]; +}; + +export type ArmorSingleSet = { id: string; label: string; detail: string }; + +export const BASE_SET_GROUPS: readonly BaseSetGroup[] = catalog.groups; + +export const BASE_SET_ENTRIES: readonly BaseSetEntry[] = catalog.base_sets.map((row) => ({ + ...row, + kinds: row.kinds as SkinKind[], +})); + /** * One armour submission is one metal line: up to one set per type, all the same - * metal. Mirrors backend ARMOR_TYPES / ARMOR_METALS. + * metal — or exactly one single set (leather, a mage school…). */ -export const ARMOR_TYPES = ["light", "medium", "heavy", "infantry", "mage"] as const; +export const ARMOR_TYPES = catalog.armor.types.map((t) => t.id) as readonly string[]; + +export type ArmorType = string; -export type ArmorType = (typeof ARMOR_TYPES)[number]; +export const ARMOR_METALS = catalog.armor.metals.map((m) => m.id) as readonly string[]; -export const ARMOR_METALS = ["iron", "steel", "bronze", "abyssalite", "mythril"] as const; +export const ARMOR_SINGLE_SETS: readonly ArmorSingleSet[] = catalog.armor.single_sets; + +/** Shared byte caps (limits in base_sets.json). */ +export const RANGED_3D_TOTAL_BYTES: number = catalog.limits.ranged_3d_total_bytes; +export const MCMETA_MAX_BYTES: number = catalog.limits.mcmeta_max_bytes; + +export function isArmorSingleSet(id: string): boolean { + return ARMOR_SINGLE_SETS.some((s) => s.id === id); +} /** Tier id sent to the backend: `light_iron`. Its shop base set is `light iron`. */ export function armorTier(type: string, metal: string): string { @@ -23,78 +57,54 @@ export function armorTierLabel(tier: string): string { .join(" "); } -const HANDHELD = [ - "swords", - "lutes", - "battleaxes", - "daggers", - "warhammers", - "shortswords", - "hatchets", - "hoes", - "knives", -] as const; - -const LARGE_HANDHELD = ["spears", "polearms", "greathammers", "staffs"] as const; - -/** Mirrors backend BASE_SETS; armour picks its metal here. */ +function setsFor(kind: SkinKind): readonly string[] { + if (kind === "armor_set") return ARMOR_METALS; + return BASE_SET_ENTRIES.filter((row) => row.kinds.includes(kind)).map((row) => row.id); +} + +/** Base sets each kind may target; armour picks its metal here. */ export const BASE_SETS: Record = { - armor_set: ARMOR_METALS, - handheld: HANDHELD, - large_handheld: LARGE_HANDHELD, - bow: ["shortbows"], - large_bow: ["longbows"], - crossbow: ["crossbows"], - item_3d: [...HANDHELD, ...LARGE_HANDHELD], - shield: ["shields"], - helmet_3d: ["helmets"], - mask: ["masks"], - gun: ["rifles", "pistols", "shotguns", "launchers"], - book: ["books"], + armor_set: setsFor("armor_set"), + handheld: setsFor("handheld"), + large_handheld: setsFor("large_handheld"), + bow: setsFor("bow"), + large_bow: setsFor("large_bow"), + crossbow: setsFor("crossbow"), + bow_3d: setsFor("bow_3d"), + crossbow_3d: setsFor("crossbow_3d"), + item_3d: setsFor("item_3d"), + shield: setsFor("shield"), + helmet_3d: setsFor("helmet_3d"), + mask: setsFor("mask"), + gun: setsFor("gun"), + book: setsFor("book"), }; -const LABELS: Record = { - iron: "Iron", - steel: "Steel", - bronze: "Bronze", - abyssalite: "Abyssalite", - mythril: "Mythril", - swords: "Swords", - lutes: "Lutes", - battleaxes: "Battleaxes", - daggers: "Daggers", - warhammers: "Warhammers", - shortswords: "Shortswords", - hatchets: "Hatchets", - hoes: "Hoes", - knives: "Knives", - spears: "Spears", - polearms: "Polearms", - greathammers: "Greathammers", - staffs: "Staffs", - shortbows: "Shortbows", - longbows: "Longbows", - crossbows: "Crossbows", - shields: "Shields", - helmets: "Helmets", - masks: "Masks", - rifles: "Rifles", - pistols: "Pistols", - shotguns: "Shotguns", - launchers: "Launchers", - books: "Books", -}; +const LABELS: Record = Object.fromEntries([ + ...catalog.armor.metals.map((m) => [m.id, m.label]), + ...catalog.armor.single_sets.map((s) => [s.id, s.label]), + ...catalog.base_sets.map((row) => [row.id, row.label]), +]); export function baseSetsForKind(kind: SkinKind): readonly string[] { return BASE_SETS[kind]; } +export function baseSetEntry(id: string): BaseSetEntry | undefined { + return BASE_SET_ENTRIES.find((row) => row.id === id); +} + +/** Upload kinds that can target a base set, in the order the file lists them. */ +export function kindsForBaseSet(id: string): readonly SkinKind[] { + return baseSetEntry(id)?.kinds ?? []; +} + export function baseSetLabel(id: string): string { return LABELS[id] ?? id; } export function defaultBaseSet(kind: SkinKind): string { - return BASE_SETS[kind][0]; + return BASE_SETS[kind][0] ?? ""; } export function baseSetPickerTitle(kind: SkinKind): string { diff --git a/frontend/lib/skins/entitlementsDev.ts b/frontend/lib/skins/entitlementsDev.ts index e1d9a7e2..5468c8e3 100644 --- a/frontend/lib/skins/entitlementsDev.ts +++ b/frontend/lib/skins/entitlementsDev.ts @@ -3,6 +3,7 @@ import type { CatalogEntitlements } from "./catalog"; const NOBLE_KINDS = ["handheld", "large_handheld", "bow", "large_bow", "crossbow", "book"]; +const ASCENDED_KINDS = ["item_3d", "shield", "helmet_3d", "mask", "gun", "bow_3d", "crossbow_3d"]; /** Matches ArmourShop permission-groups.yml (ui-dev uses ascended-level unlock). */ export const DEV_CATALOG_ENTITLEMENTS: CatalogEntitlements = { @@ -44,7 +45,7 @@ export const DEV_CATALOG_ENTITLEMENTS: CatalogEntitlements = { name_colour_stops: 20, max_3d_pair_bytes: 30720, skin_token_cooldown_days: 14, - skin_kinds: ["item_3d", "shield", "helmet_3d", "mask", "gun"], + skin_kinds: ASCENDED_KINDS, allow_armor_3d_helmet: true, }, { @@ -62,4 +63,4 @@ export const DEV_CATALOG_ENTITLEMENTS: CatalogEntitlements = { }; /** Resolved ascended-level kinds for local upload smoke without a redeem. */ -export const DEV_SESSION_SKIN_KINDS = [...NOBLE_KINDS, "armor_set", "item_3d", "shield", "helmet_3d", "mask", "gun"]; +export const DEV_SESSION_SKIN_KINDS = [...NOBLE_KINDS, "armor_set", ...ASCENDED_KINDS]; diff --git a/frontend/lib/skins/kindAccess.test.ts b/frontend/lib/skins/kindAccess.test.ts new file mode 100644 index 00000000..8c7b5d1b --- /dev/null +++ b/frontend/lib/skins/kindAccess.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { DEV_CATALOG_ENTITLEMENTS } from "./entitlementsDev"; +import { firstAllowedKind, kindAllowed, kindsForTarget, rankForKind } from "./kindAccess"; + +describe("kindsForTarget", () => { + it("returns the item's styles, flat first", () => { + expect(kindsForTarget({ type: "item", baseSet: "longbows" })).toEqual(["large_bow", "bow_3d"]); + expect(kindsForTarget({ type: "single", set: "leather" })).toEqual(["armor_set"]); + }); +}); + +describe("rank locks", () => { + it("names the lowest rank that grants a kind", () => { + expect(rankForKind(DEV_CATALOG_ENTITLEMENTS, "bow_3d")).toBe("Ascended"); + expect(rankForKind(DEV_CATALOG_ENTITLEMENTS, "handheld")).toBe("Noble"); + expect(rankForKind(DEV_CATALOG_ENTITLEMENTS, "nope")).toBeNull(); + }); + + it("treats a missing list as everything (staff)", () => { + expect(kindAllowed("bow_3d", null)).toBe(true); + expect(kindAllowed("bow_3d", ["bow"])).toBe(false); + }); + + it("starts on the first style the player can use", () => { + expect(firstAllowedKind({ type: "item", baseSet: "wands" }, ["item_3d"])).toBe("item_3d"); + expect(firstAllowedKind({ type: "item", baseSet: "wands" }, ["bow"])).toBeNull(); + }); +}); diff --git a/frontend/lib/skins/kindAccess.ts b/frontend/lib/skins/kindAccess.ts new file mode 100644 index 00000000..5579bb0b --- /dev/null +++ b/frontend/lib/skins/kindAccess.ts @@ -0,0 +1,50 @@ +import type { CatalogEntitlements } from "./catalog"; +import type { SkinKind } from "./sizes"; +import { ARMOR_METALS, ARMOR_SINGLE_SETS, kindsForBaseSet } from "./baseSets"; + +/** What a skin goes on: an item base set, an armour metal line, or one single armour set. */ +export type SkinTarget = + | { type: "item"; baseSet: string } + | { type: "metal"; metal: string } + | { type: "single"; set: string }; + +/** Upload kinds that can skin a target, in base_sets.json order. */ +export function kindsForTarget(target: SkinTarget): readonly SkinKind[] { + if (target.type === "item") return kindsForBaseSet(target.baseSet); + return ["armor_set"]; +} + +export function sameTarget(a: SkinTarget | null, b: SkinTarget | null): boolean { + if (!a || !b) return a === b; + if (a.type === "item" && b.type === "item") return a.baseSet === b.baseSet; + if (a.type === "metal" && b.type === "metal") return a.metal === b.metal; + if (a.type === "single" && b.type === "single") return a.set === b.set; + return false; +} + +/** Every armour target: metal lines first, then single sets. */ +export const ARMOR_TARGETS: readonly SkinTarget[] = [ + ...ARMOR_METALS.map((metal) => ({ type: "metal" as const, metal })), + ...ARMOR_SINGLE_SETS.map((s) => ({ type: "single" as const, set: s.id })), +]; + +/** `null` allowed list means everything (staff); otherwise case-insensitive membership. */ +export function kindAllowed(kind: string, allowed: readonly string[] | null | undefined): boolean { + if (allowed == null) return true; + return allowed.some((k) => k.toLowerCase() === kind); +} + +/** + * The lowest rank whose group lists a kind (ranks include every lower rank's kinds), or + * null when no rank grants it. + */ +export function rankForKind(entitlements: CatalogEntitlements | null | undefined, kind: string): string | null { + const groups = [...(entitlements?.groups ?? [])].sort((a, b) => a.tier - b.tier); + const group = groups.find((g) => g.skin_kinds.some((k) => k.toLowerCase() === kind)); + return group ? group.display_name || group.id : null; +} + +/** First kind for a target the player may upload, else null. */ +export function firstAllowedKind(target: SkinTarget, allowed: readonly string[] | null | undefined): SkinKind | null { + return kindsForTarget(target).find((kind) => kindAllowed(kind, allowed)) ?? null; +} diff --git a/frontend/lib/skins/ownerFiles.test.ts b/frontend/lib/skins/ownerFiles.test.ts index 419dd59c..11a3e16c 100644 --- a/frontend/lib/skins/ownerFiles.test.ts +++ b/frontend/lib/skins/ownerFiles.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "vitest"; -import { ownerFileNames } from "./ownerFiles"; +import { ownerAnimationFileNames, ownerFileNames } from "./ownerFiles"; describe("ownerFileNames", () => { it("names each armour set's layers, icons and 3D helmet", () => { - const names = ownerFileNames({ kind: "armor_set", slug: "x", tiers: ["iron", "mage"] }); + const names = ownerFileNames({ + kind: "armor_set", + slug: "x", + tiers: ["iron", "mage"], + }); expect(names.iron_layer_1).toBe("iron_layer_1.png"); expect(names.mage_boots).toBe("mage_boots.png"); expect(names.iron_helmet_model).toBe("iron_helmet_model.json"); @@ -20,9 +24,45 @@ describe("ownerFileNames", () => { }); it("pairs 3D models with their textures, and books with both covers", () => { - expect(ownerFileNames({ kind: "helmet_3d", slug: "hat" })).toEqual({ texture: "hat.png", model: "hat.json" }); + expect(ownerFileNames({ kind: "helmet_3d", slug: "hat" })).toEqual({ + texture: "hat.png", + model: "hat.json", + }); expect(ownerFileNames({ kind: "gun", slug: "g" }).aim_model).toBe("g_aim.json"); - expect(ownerFileNames({ kind: "book", slug: "j" })).toEqual({ unsigned: "j_unsigned.png", signed: "j_signed.png" }); - expect(ownerFileNames({ kind: "large_handheld", slug: "staff" })).toEqual({ texture: "staff.png" }); + expect(ownerFileNames({ kind: "book", slug: "j" })).toEqual({ + unsigned: "j_unsigned.png", + signed: "j_signed.png", + }); + expect(ownerFileNames({ kind: "large_handheld", slug: "staff" })).toEqual({ + texture: "staff.png", + }); + }); + + it("names 3D bow models by draw stage", () => { + expect(ownerFileNames({ kind: "crossbow_3d", slug: "cb" })).toEqual({ + texture: "cb.png", + model: "cb.json", + pull_0_model: "cb_0.json", + pull_1_model: "cb_1.json", + pull_2_model: "cb_2.json", + charged_model: "cb_charged.json", + }); + }); + + it("looks for an .mcmeta beside each texture", () => { + expect(ownerAnimationFileNames({ kind: "bow_3d", slug: "b" })).toEqual({ + texture_mcmeta: "b.png.mcmeta", + }); + expect(ownerAnimationFileNames({ kind: "book", slug: "j" })).toEqual({ + unsigned_mcmeta: "j_unsigned.png.mcmeta", + signed_mcmeta: "j_signed.png.mcmeta", + }); + expect( + ownerAnimationFileNames({ + kind: "armor_set", + slug: "x", + tiers: ["iron"], + }), + ).toEqual({}); }); }); diff --git a/frontend/lib/skins/ownerFiles.ts b/frontend/lib/skins/ownerFiles.ts index c98cc0af..e87cd0ff 100644 --- a/frontend/lib/skins/ownerFiles.ts +++ b/frontend/lib/skins/ownerFiles.ts @@ -1,5 +1,5 @@ import type { SubmissionPublic } from "./api"; -import { ARMOR_FIELDS } from "./sizes"; +import { ARMOR_FIELDS, isModelField, mcmetaField } from "./sizes"; /** * The stored files a skin's 3D viewer needs, keyed by the upload form's field names @@ -19,7 +19,12 @@ export function ownerFileNames(row: Pick = { + texture: `${slug}.png`, + model: `${slug}.json`, + pull_0_model: `${slug}_0.json`, + pull_1_model: `${slug}_1.json`, + pull_2_model: `${slug}_2.json`, + }; + if (kind === "crossbow_3d") names.charged_model = `${slug}_charged.json`; + return names; + } case "item_3d": case "shield": case "helmet_3d": @@ -46,3 +63,18 @@ export function ownerFileNames(row: Pick.mcmeta`), keyed `_mcmeta`. + * Armour is left out: its showcase draws the worn layers, which never animate. + */ +export function ownerAnimationFileNames( + row: Pick, +): Record { + if (row.kind === "armor_set") return {}; + const names: Record = {}; + for (const [field, name] of Object.entries(ownerFileNames(row))) { + if (!isModelField(field) && name.endsWith(".png")) names[mcmetaField(field)] = `${name}.mcmeta`; + } + return names; +} diff --git a/frontend/lib/skins/sizes.test.ts b/frontend/lib/skins/sizes.test.ts index 4b4f0c5d..27a0a909 100644 --- a/frontend/lib/skins/sizes.test.ts +++ b/frontend/lib/skins/sizes.test.ts @@ -1,5 +1,17 @@ import { describe, expect, it } from "vitest"; -import { pairBudgetHint } from "./sizes"; +import { + assertRanged3dBudget, + expectedSizeForField, + fileFieldsForKind, + formatBytes, + mcmetaAllowed, + mcmetaField, + pairBudgetHint, +} from "./sizes"; + +function fakeFile(bytes: number, name = "f"): File { + return new File([new Uint8Array(bytes)], name); +} describe("pairBudgetHint", () => { it("is empty without a budget", () => { @@ -17,3 +29,52 @@ describe("pairBudgetHint", () => { expect(pairBudgetHint(800)).toBe("Model + texture: max 800 bytes"); }); }); + +describe("3D bow and crossbow fields", () => { + it("asks for one texture and a model per draw stage", () => { + expect(fileFieldsForKind("bow_3d")).toEqual(["texture", "model", "pull_0_model", "pull_1_model", "pull_2_model"]); + expect(fileFieldsForKind("crossbow_3d")).toEqual([ + "texture", + "model", + "pull_0_model", + "pull_1_model", + "pull_2_model", + "charged_model", + ]); + expect(expectedSizeForField("bow_3d", "texture")).toBeNull(); + }); + + it("caps every file together at 60 KB, for any rank", () => { + const files = { + texture: fakeFile(20_000), + model: fakeFile(20_000), + texture_mcmeta: fakeFile(1_000), + }; + expect(() => assertRanged3dBudget("bow_3d", files)).not.toThrow(); + expect(() => + assertRanged3dBudget("bow_3d", { + ...files, + pull_0_model: fakeFile(21_000), + }), + ).toThrow("60 KB"); + // Other kinds are not limited here. + expect(() => assertRanged3dBudget("item_3d", { model: fakeFile(100_000) })).not.toThrow(); + }); + + it("formats sizes like the hints", () => { + expect(formatBytes(61_440)).toBe("60 KB"); + expect(formatBytes(900)).toBe("900 bytes"); + }); +}); + +describe("animation slots", () => { + it("allows .mcmeta on textures but not on models or armour layers", () => { + expect(mcmetaAllowed("texture")).toBe(true); + expect(mcmetaAllowed("pull_1")).toBe(true); + expect(mcmetaAllowed("helmet_texture")).toBe(true); + expect(mcmetaAllowed("model")).toBe(false); + expect(mcmetaAllowed("pull_0_model")).toBe(false); + expect(mcmetaAllowed("layer_1")).toBe(false); + expect(mcmetaField("unsigned")).toBe("unsigned_mcmeta"); + }); +}); diff --git a/frontend/lib/skins/sizes.ts b/frontend/lib/skins/sizes.ts index bb28e17b..826014cd 100644 --- a/frontend/lib/skins/sizes.ts +++ b/frontend/lib/skins/sizes.ts @@ -1,3 +1,6 @@ +import { assertAnimatedTexture, readTextureAnimationFile } from "../textureAnimation"; +import { RANGED_3D_TOTAL_BYTES } from "./baseSets"; + export type SkinKind = | "armor_set" | "handheld" @@ -5,6 +8,8 @@ export type SkinKind = | "bow" | "large_bow" | "crossbow" + | "bow_3d" + | "crossbow_3d" | "item_3d" | "shield" | "helmet_3d" @@ -33,6 +38,9 @@ export const BOW_FRAME_FIELDS = ["texture", ...BOW_PULL_FIELDS] as const; export const CROSSBOW_FRAME_FIELDS = [...BOW_FRAME_FIELDS, "charged"] as const; export const MODEL_3D_FIELDS = ["texture", "model"] as const; +/** 3D bow: one texture, a model for standby and each pull stage. */ +export const BOW_3D_FIELDS = ["texture", "model", "pull_0_model", "pull_1_model", "pull_2_model"] as const; +export const CROSSBOW_3D_FIELDS = [...BOW_3D_FIELDS, "charged_model"] as const; export const GUN_FIELDS = ["texture", "carry_model", "reload_model", "aim_model"] as const; export const BOOK_FIELDS = ["unsigned", "signed"] as const; @@ -48,6 +56,11 @@ export function isModel3dKind(kind: SkinKind): boolean { return kind === "item_3d" || kind === "shield" || kind === "helmet_3d" || kind === "mask"; } +/** 3D bow / crossbow: a model per draw stage sharing one texture. */ +export function isRanged3dKind(kind: SkinKind | string | undefined): boolean { + return kind === "bow_3d" || kind === "crossbow_3d"; +} + export function isGunKind(kind: SkinKind): boolean { return kind === "gun"; } @@ -69,6 +82,12 @@ export function fileFieldsForKind(kind: SkinKind): readonly string[] { if (isModel3dKind(kind)) { return MODEL_3D_FIELDS; } + if (kind === "bow_3d") { + return BOW_3D_FIELDS; + } + if (kind === "crossbow_3d") { + return CROSSBOW_3D_FIELDS; + } if (isGunKind(kind)) { return GUN_FIELDS; } @@ -97,7 +116,7 @@ export function expectedSizeForField(kind: SkinKind, field: string): Size | null } return null; } - if (isModel3dKind(kind) || isGunKind(kind)) { + if (isModel3dKind(kind) || isGunKind(kind) || isRanged3dKind(kind)) { return null; } if (isBowFrameKind(kind)) { @@ -207,3 +226,67 @@ export function pairBudgetHint(maxBytes: number | undefined): string { if (kb < 1) return `Model + texture: max ${maxBytes} bytes`; return `Model + texture: max ${kb >= 10 ? Math.floor(kb) : kb} KB`; } + +/** Upload fields that take a model JSON rather than a PNG. */ +export function isModelField(field: string): boolean { + return field === "model" || field.endsWith("_model"); +} + +/** Optional `.mcmeta` upload field for a PNG field (`texture` → `texture_mcmeta`). */ +export function mcmetaField(field: string): string { + return `${field}_mcmeta`; +} + +/** Every PNG may be animated except the worn armour layers. */ +export function mcmetaAllowed(field: string): boolean { + if (isModelField(field)) return false; + return !(ARMOR_LAYER_FIELDS as readonly string[]).includes(field); +} + +/** + * Check one PNG slot. Without `.mcmeta` a fixed slot must be exactly `expected`; with one, + * each frame must be `expected` (any size for free-size 3D textures). + */ +export async function assertTextureFile( + file: File, + expected: Size | null, + mcmeta: File | null | undefined, + label: string, +): Promise { + if (!mcmeta) { + if (expected) await assertFileSize(file, expected, label); + return; + } + const metadata = await readTextureAnimationFile(mcmeta, `${label} animation`); + const size = await readPngSize(file); + assertAnimatedTexture(size, metadata, expected, label); +} + +/** Sum of every uploaded file for a 3D bow / crossbow (models, texture and `.mcmeta`). */ +export function ranged3dTotalBytes(files: Record): number { + return Object.values(files).reduce((sum, file) => sum + (file?.size ?? 0), 0); +} + +/** 3D bows and crossbows share one fixed cap for all their files, the same for every rank. */ +export function assertRanged3dBudget( + kind: SkinKind, + files: Record, + maxBytes: number = RANGED_3D_TOTAL_BYTES, +): void { + if (!isRanged3dKind(kind)) return; + const total = ranged3dTotalBytes(files); + if (total > maxBytes) { + throw new Error( + `All files together are ${formatBytes(total)} (${total} bytes); the limit for a 3D ${ + kind === "crossbow_3d" ? "crossbow" : "bow" + } is ${formatBytes(maxBytes)} (${maxBytes} bytes)`, + ); + } +} + +/** "60 KB" style size, rounded down to one decimal under 10 KB. */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} bytes`; + const kb = Math.floor((bytes / 1024) * 10) / 10; + return `${kb >= 10 ? Math.floor(kb) : kb} KB`; +} diff --git a/frontend/lib/textureAnimation.test.ts b/frontend/lib/textureAnimation.test.ts new file mode 100644 index 00000000..f066e07f --- /dev/null +++ b/frontend/lib/textureAnimation.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { + assertAnimatedTexture, + frameIndexAt, + frameRect, + frameGrid, + frameSchedule, + frameSizeFor, + parseTextureAnimation, +} from "./textureAnimation"; + +describe("Minecraft frame size rules", () => { + it("uses a square of the shorter side when width and height are unset", () => { + expect(frameSizeFor(16, 64, {})).toEqual({ w: 16, h: 16 }); + expect(frameSizeFor(64, 16, {})).toEqual({ w: 16, h: 16 }); + }); + + it("takes the whole image for the side that is not given", () => { + expect(frameSizeFor(32, 64, { width: 16 })).toEqual({ w: 16, h: 64 }); + expect(frameSizeFor(32, 64, { height: 16 })).toEqual({ w: 32, h: 16 }); + expect(frameSizeFor(32, 64, { width: 16, height: 32 })).toEqual({ + w: 16, + h: 32, + }); + }); + + it("reads a grid left to right, then top to bottom", () => { + const grid = frameGrid(32, 32, { width: 16, height: 16 }); + expect(grid.count).toBe(4); + expect(frameRect(1, grid)).toEqual({ x: 16, y: 0, w: 16, h: 16 }); + expect(frameRect(2, grid)).toEqual({ x: 0, y: 16, w: 16, h: 16 }); + }); +}); + +describe("animation timing", () => { + it("loops frames for frametime ticks each", () => { + const schedule = frameSchedule({ frametime: 2 }, 3); + expect(schedule.map((f) => f.duration)).toEqual([100, 100, 100]); + expect(frameIndexAt(schedule, 0)).toBe(0); + expect(frameIndexAt(schedule, 150)).toBe(1); + expect(frameIndexAt(schedule, 300)).toBe(0); + }); + + it("honours explicit frames and per-frame times", () => { + const schedule = frameSchedule({ frames: [2, { index: 0, time: 3 }] }, 3); + expect(frameIndexAt(schedule, 49)).toBe(2); + expect(frameIndexAt(schedule, 50)).toBe(0); + expect(frameIndexAt(schedule, 199)).toBe(0); + }); +}); + +describe("parseTextureAnimation", () => { + it("accepts a normal mcmeta", () => { + expect(parseTextureAnimation('{"animation":{"frametime":4,"interpolate":true}}')).toEqual({ + animation: { frametime: 4, interpolate: true }, + }); + }); + + it("rejects bad JSON and bad fields with readable messages", () => { + expect(() => parseTextureAnimation("{")).toThrow("not valid JSON"); + expect(() => parseTextureAnimation("{}")).toThrow('"animation"'); + expect(() => parseTextureAnimation('{"animation":{"frametime":0}}')).toThrow("frametime"); + expect(() => parseTextureAnimation('{"animation":{"frames":[-1]}}')).toThrow("frame"); + expect(() => parseTextureAnimation('{"animation":{"width":1.5}}')).toThrow("width"); + }); +}); + +describe("assertAnimatedTexture", () => { + it("lets a fixed 16×16 slot hold a column of 16×16 frames", () => { + expect(assertAnimatedTexture({ w: 16, h: 64 }, { animation: {} }, { w: 16, h: 16 }, "Texture")).toBe(4); + }); + + it("rejects frames of the wrong size for a fixed slot", () => { + expect(() => assertAnimatedTexture({ w: 32, h: 64 }, { animation: {} }, { w: 16, h: 16 }, "Texture")).toThrow( + "16×16", + ); + }); + + it("rejects images that frames do not divide, and out-of-range frame indices", () => { + expect(() => assertAnimatedTexture({ w: 16, h: 40 }, { animation: {} }, null, "Texture")).toThrow("evenly"); + expect(() => assertAnimatedTexture({ w: 16, h: 32 }, { animation: { frames: [2] } }, null, "Texture")).toThrow( + "between 0 and 1", + ); + }); +}); diff --git a/frontend/lib/textureAnimation.ts b/frontend/lib/textureAnimation.ts new file mode 100644 index 00000000..9428019e --- /dev/null +++ b/frontend/lib/textureAnimation.ts @@ -0,0 +1,221 @@ +/** + * Minecraft `.png.mcmeta` texture animation: frame geometry, timing and validation. + * Shared by the wiki station viewer and the skin previews; mirrors the skins API rules. + */ + +export type TextureAnimationFrame = number | { index: number; time?: number }; + +export type TextureAnimationMetadata = { + animation: { + frametime?: number; + width?: number; + height?: number; + interpolate?: boolean; + frames?: TextureAnimationFrame[]; + }; +}; + +export type FrameSize = { w: number; h: number }; + +export type ScheduledFrame = { index: number; duration: number }; + +/** One Minecraft game tick in milliseconds; animation times are in ticks. */ +export const TICK_MS = 50; + +/** Largest `.mcmeta` the skins API accepts (shared limits.mcmeta_max_bytes). */ +export const MCMETA_MAX_BYTES = 2048; + +/** + * Frame size as Minecraft resolves it: both width and height given → that; only one → + * the other side is the whole image; neither → a square of the shorter side. + */ +export function frameSizeFor( + imageWidth: number, + imageHeight: number, + animation: TextureAnimationMetadata["animation"], +): FrameSize { + const { width, height } = animation; + if (width !== undefined && height !== undefined) return { w: width, h: height }; + if (width !== undefined) return { w: width, h: imageHeight }; + if (height !== undefined) return { w: imageWidth, h: height }; + const side = Math.min(imageWidth, imageHeight); + return { w: side, h: side }; +} + +/** Columns × rows of frames, read left to right then top to bottom. */ +export function frameGrid( + imageWidth: number, + imageHeight: number, + animation: TextureAnimationMetadata["animation"], +): { size: FrameSize; columns: number; rows: number; count: number } { + const size = frameSizeFor(imageWidth, imageHeight, animation); + const columns = imageWidth / size.w; + const rows = imageHeight / size.h; + if (!Number.isInteger(columns) || !Number.isInteger(rows) || columns < 1 || rows < 1) { + throw new Error( + `Animation frame size ${size.w}×${size.h} does not divide the ${imageWidth}×${imageHeight} texture evenly (invalid frame dimensions).`, + ); + } + return { size, columns, rows, count: columns * rows }; +} + +/** Frame order and per-frame durations (ms); default order is every frame once. */ +export function frameSchedule(animation: TextureAnimationMetadata["animation"], frameCount: number): ScheduledFrame[] { + const frameTime = animation.frametime ?? 1; + const frames = (animation.frames ?? Array.from({ length: frameCount }, (_, i) => i)).map((frame) => ({ + index: typeof frame === "number" ? frame : frame.index, + duration: (typeof frame === "number" ? frameTime : (frame.time ?? frameTime)) * TICK_MS, + })); + if ( + !frames.length || + frames.some( + ({ index, duration }) => + !Number.isInteger(index) || index < 0 || index >= frameCount || !Number.isFinite(duration) || duration <= 0, + ) + ) { + throw new Error("Invalid animation frames."); + } + return frames; +} + +/** Which source frame shows `elapsedMs` into a looping schedule. */ +export function frameIndexAt(schedule: ScheduledFrame[], elapsedMs: number): number { + const total = schedule.reduce((sum, frame) => sum + frame.duration, 0); + let time = Math.max(0, elapsedMs) % total; + for (const frame of schedule) { + if (time < frame.duration) return frame.index; + time -= frame.duration; + } + return schedule[schedule.length - 1]!.index; +} + +/** Pixel rectangle of frame `index` in the source image. */ +export function frameRect( + index: number, + grid: { size: FrameSize; columns: number }, +): { x: number; y: number; w: number; h: number } { + return { + x: (index % grid.columns) * grid.size.w, + y: Math.floor(index / grid.columns) * grid.size.h, + w: grid.size.w, + h: grid.size.h, + }; +} + +/** + * Texture repeat/offset for each moment of the animation, for a texture uploaded with + * Three's default `flipY = true` (frame zero is at the top of the PNG). + */ +export function createTextureAnimation(imageWidth: number, imageHeight: number, metadata: TextureAnimationMetadata) { + const grid = frameGrid(imageWidth, imageHeight, metadata.animation); + const schedule = frameSchedule(metadata.animation, grid.count); + return (elapsedMs: number) => { + const index = frameIndexAt(schedule, elapsedMs); + return { + repeatX: 1 / grid.columns, + repeatY: 1 / grid.rows, + offsetX: (index % grid.columns) / grid.columns, + offsetY: 1 - (Math.floor(index / grid.columns) + 1) / grid.rows, + }; + }; +} + +function isPositiveInt(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 1; +} + +function isIndex(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} + +/** Parse and check `.mcmeta` text; throws with a player-facing message. */ +export function parseTextureAnimation(text: string, label = "Animation"): TextureAnimationMetadata { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + throw new Error(`${label}: .mcmeta is not valid JSON`); + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error(`${label}: .mcmeta must be a JSON object`); + } + const animation = (raw as Record).animation; + if (!animation || typeof animation !== "object" || Array.isArray(animation)) { + throw new Error(`${label}: .mcmeta needs an "animation" object`); + } + const a = animation as Record; + const out: TextureAnimationMetadata["animation"] = {}; + if (a.frametime !== undefined) { + if (!isPositiveInt(a.frametime)) throw new Error(`${label}: frametime must be a whole number of ticks, 1 or more`); + out.frametime = a.frametime; + } + for (const key of ["width", "height"] as const) { + if (a[key] !== undefined) { + if (!isPositiveInt(a[key])) throw new Error(`${label}: ${key} must be a whole number, 1 or more`); + out[key] = a[key]; + } + } + if (a.interpolate !== undefined) { + if (typeof a.interpolate !== "boolean") throw new Error(`${label}: interpolate must be true or false`); + out.interpolate = a.interpolate; + } + if (a.frames !== undefined) { + if (!Array.isArray(a.frames) || a.frames.length === 0) { + throw new Error(`${label}: frames must be a non-empty list`); + } + out.frames = a.frames.map((frame) => { + if (isIndex(frame)) return frame; + if (frame && typeof frame === "object" && !Array.isArray(frame)) { + const f = frame as Record; + if (isIndex(f.index) && (f.time === undefined || isPositiveInt(f.time))) { + return f.time === undefined ? { index: f.index } : { index: f.index, time: f.time }; + } + } + throw new Error(`${label}: each frame must be an index or {"index": n, "time": ticks}`); + }); + } + return { animation: out }; +} + +/** + * Check an animated texture against its `.mcmeta`. With `expected`, every frame must be + * that size (a fixed 16×16 or 32×32 slot may then hold a grid of frames). + * Returns the frame count. + */ +export function assertAnimatedTexture( + image: FrameSize, + metadata: TextureAnimationMetadata, + expected: FrameSize | null, + label: string, +): number { + let grid: ReturnType; + try { + grid = frameGrid(image.w, image.h, metadata.animation); + } catch (err) { + throw new Error(`${label}: ${err instanceof Error ? err.message : "invalid animation"}`); + } + if (expected && (grid.size.w !== expected.w || grid.size.h !== expected.h)) { + throw new Error( + `${label}: each animation frame must be ${expected.w}×${expected.h}, got ${grid.size.w}×${grid.size.h} frames in a ${image.w}×${image.h} image`, + ); + } + try { + frameSchedule(metadata.animation, grid.count); + } catch { + throw new Error(`${label}: animation frames must be between 0 and ${grid.count - 1}`); + } + return grid.count; +} + +/** Read, size-check and parse an uploaded `.mcmeta` file. */ +export async function readTextureAnimationFile(file: File, label = "Animation"): Promise { + if (file.size > MCMETA_MAX_BYTES) { + throw new Error(`${label}: .mcmeta is ${file.size} bytes (limit ${MCMETA_MAX_BYTES} bytes)`); + } + return parseTextureAnimation(await file.text(), label); +} + +/** True when the viewer asked the OS for less motion. */ +export function prefersReducedMotion(): boolean { + return typeof window !== "undefined" && Boolean(window.matchMedia?.("(prefers-reduced-motion: reduce)").matches); +} diff --git a/shared/skins/base_sets.json b/shared/skins/base_sets.json new file mode 100644 index 00000000..cb314804 --- /dev/null +++ b/shared/skins/base_sets.json @@ -0,0 +1,78 @@ +{ + "comment": "What a player skin can apply to. id is the ArmourShop base-sets.yml key, kinds are the upload kinds that may target it. Read by backend/src/skins/base_sets.py and frontend/lib/skins/baseSets.ts.", + "groups": [ + { "id": "melee", "label": "Melee" }, + { "id": "magic", "label": "Magic" }, + { "id": "ranged", "label": "Ranged" }, + { "id": "tools", "label": "Tools" }, + { "id": "other", "label": "Other" } + ], + "base_sets": [ + { "id": "swords", "label": "Swords", "group": "melee", "kinds": ["handheld", "item_3d"] }, + { "id": "shortswords", "label": "Shortswords", "group": "melee", "kinds": ["handheld", "item_3d"] }, + { "id": "longswords", "label": "Longswords", "group": "melee", "kinds": ["large_handheld", "item_3d"] }, + { "id": "daggers", "label": "Daggers", "group": "melee", "kinds": ["handheld", "item_3d"] }, + { "id": "battleaxes", "label": "Battleaxes", "group": "melee", "kinds": ["handheld", "item_3d"] }, + { "id": "greataxes", "label": "Greataxes", "group": "melee", "kinds": ["large_handheld", "item_3d"] }, + { "id": "warhammers", "label": "Warhammers", "group": "melee", "kinds": ["handheld", "item_3d"] }, + { "id": "greathammers", "label": "Greathammers", "group": "melee", "kinds": ["large_handheld", "item_3d"] }, + { "id": "spears", "label": "Spears", "group": "melee", "kinds": ["large_handheld", "item_3d"] }, + { "id": "polearms", "label": "Polearms", "group": "melee", "kinds": ["large_handheld", "item_3d"] }, + { "id": "staffs", "label": "Staffs", "group": "magic", "kinds": ["large_handheld", "item_3d"] }, + { "id": "wands", "label": "Wands", "group": "magic", "kinds": ["handheld", "item_3d"] }, + { "id": "mage blades", "label": "Mage blades", "group": "magic", "kinds": ["handheld", "item_3d"] }, + { "id": "shortbows", "label": "Shortbows", "group": "ranged", "kinds": ["bow", "bow_3d"] }, + { "id": "longbows", "label": "Longbows", "group": "ranged", "kinds": ["large_bow", "bow_3d"] }, + { "id": "crossbows", "label": "Crossbows", "group": "ranged", "kinds": ["crossbow", "crossbow_3d"] }, + { "id": "rifles", "label": "Rifles", "group": "ranged", "kinds": ["gun"] }, + { "id": "pistols", "label": "Pistols", "group": "ranged", "kinds": ["gun"] }, + { "id": "shotguns", "label": "Shotguns", "group": "ranged", "kinds": ["gun"] }, + { "id": "launchers", "label": "Launchers", "group": "ranged", "kinds": ["gun"] }, + { "id": "hatchets", "label": "Hatchets", "group": "tools", "kinds": ["handheld", "item_3d"] }, + { "id": "hoes", "label": "Hoes", "group": "tools", "kinds": ["handheld", "item_3d"] }, + { "id": "pickaxes", "label": "Pickaxes", "group": "tools", "kinds": ["handheld", "item_3d"] }, + { "id": "shovels", "label": "Shovels", "group": "tools", "kinds": ["handheld", "item_3d"] }, + { "id": "knives", "label": "Knives", "group": "tools", "kinds": ["handheld", "item_3d"] }, + { "id": "fishing rods", "label": "Fishing rods", "group": "tools", "kinds": ["handheld", "item_3d"] }, + { "id": "shields", "label": "Shields", "group": "other", "kinds": ["shield"] }, + { "id": "helmets", "label": "Helmets", "group": "other", "kinds": ["helmet_3d"] }, + { "id": "masks", "label": "Masks", "group": "other", "kinds": ["mask"] }, + { "id": "battle standards", "label": "Battle standards", "group": "other", "kinds": ["item_3d"] }, + { "id": "lutes", "label": "Lutes", "group": "other", "kinds": ["handheld", "item_3d"] }, + { "id": "instruments", "label": "Instruments", "group": "other", "kinds": ["handheld", "item_3d"] }, + { "id": "books", "label": "Books", "group": "other", "kinds": ["book"] } + ], + "armor": { + "comment": "Metal lines pick up to one set per type of one metal (tier light_iron, base set 'light iron'). Single sets are one tier each; their base set is the tier with underscores as spaces.", + "types": [ + { "id": "light", "label": "Light" }, + { "id": "medium", "label": "Medium" }, + { "id": "heavy", "label": "Heavy" }, + { "id": "infantry", "label": "Infantry" }, + { "id": "mage", "label": "Mage" } + ], + "metals": [ + { "id": "iron", "label": "Iron" }, + { "id": "steel", "label": "Steel" }, + { "id": "bronze", "label": "Bronze" }, + { "id": "abyssalite", "label": "Abyssalite" }, + { "id": "mythril", "label": "Mythril" } + ], + "single_sets": [ + { "id": "leather", "label": "Leather", "detail": "Leather armour" }, + { "id": "illusion", "label": "Illusion", "detail": "Illusion mage robes" }, + { "id": "spiritual", "label": "Spiritual", "detail": "Spiritual mage robes" }, + { "id": "arcanum", "label": "Arcanum", "detail": "Arcanum mage robes" }, + { "id": "bloodmagic", "label": "Bloodmagic", "detail": "Bloodmagic mage robes" }, + { "id": "necromancy", "label": "Necromancy", "detail": "Necromancy mage robes" }, + { "id": "shadowmancy", "label": "Shadowmancy", "detail": "Shadowmancy mage robes" }, + { "id": "light_mage", "label": "Light Mage", "detail": "Illusion, spiritual and arcanum robes" }, + { "id": "dark_mage", "label": "Dark Mage", "detail": "Bloodmagic, necromancy and shadowmancy robes" }, + { "id": "wild_mage", "label": "Wild Mage", "detail": "All six school robes" } + ] + }, + "limits": { + "ranged_3d_total_bytes": 61440, + "mcmeta_max_bytes": 2048 + } +} From 2144b6097986a13dc4a5f8bb5b0608a7c867a0ea Mon Sep 17 00:00:00 2001 From: Drefvelin <54400154+Drefvelin@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:05:55 +0000 Subject: [PATCH 2/3] Fix review findings: 3D bows in lore items, .mcmeta in size checks and previews, stricter .mcmeta parsing Also make test_ban_events use times near now; its fixed October dates expired and dropped out of the ban sync. Co-Authored-By: Claude Opus 5.5 --- backend/src/skins/test_ban_events.py | 4 +- .../components/character/LoreItemEditor.tsx | 41 +++++++++++-------- .../app/components/profile/SkinShowcase.tsx | 1 + .../app/components/skins/ArmorPreview.tsx | 26 +++++++++++- .../app/components/skins/UploadForm.test.tsx | 10 +++++ frontend/app/components/skins/UploadForm.tsx | 19 ++++++++- frontend/lib/skins/ownerFiles.test.ts | 2 +- frontend/lib/skins/ownerFiles.ts | 8 ++-- frontend/lib/skins/sizes.test.ts | 36 ++++++++++++++-- frontend/lib/skins/sizes.ts | 27 ++++++++---- frontend/lib/textureAnimation.test.ts | 15 ++++++- frontend/lib/textureAnimation.ts | 35 ++++++++++++++-- 12 files changed, 184 insertions(+), 40 deletions(-) diff --git a/backend/src/skins/test_ban_events.py b/backend/src/skins/test_ban_events.py index 6e71af3c..7facdeb4 100644 --- a/backend/src/skins/test_ban_events.py +++ b/backend/src/skins/test_ban_events.py @@ -168,7 +168,9 @@ def test_an_older_ban_running_out_does_not_lift_a_later_one(): def test_times_round_trip_exactly_and_out_of_range_times_are_rejected(): - for ms in (1792352364123, 1792352364999, 1792438773975): + # Fixed millisecond parts near now: fixed past dates expire and drop out of the sync. + second = _now_ms() // 1000 * 1000 + for ms in (second + 123, second + 999, second + 86_400_975): enqueue_ban_event("ban", player_uuid=UUID, banned_at_ms=ms, expires_at_ms=ms + DAY) assert list_ban_sync()["bans"][0]["banned_at_ms"] == ms _unban() diff --git a/frontend/app/components/character/LoreItemEditor.tsx b/frontend/app/components/character/LoreItemEditor.tsx index 79a38c4f..3b9f1248 100644 --- a/frontend/app/components/character/LoreItemEditor.tsx +++ b/frontend/app/components/character/LoreItemEditor.tsx @@ -25,6 +25,7 @@ import { assertFileSize, expectedSizeForField, isModel3dKind, + isRanged3dKind, pairBudgetHint, type SkinKind, } from "../../../lib/skins/sizes"; @@ -49,6 +50,11 @@ const KNOWN_SKIN_KINDS = new Set([ "book", ]); +/** Kinds whose skin is a model + texture (a 3D bow's standby model stands in for the bow). */ +function isModelSkinKind(kind: SkinKind): boolean { + return isModel3dKind(kind) || isRanged3dKind(kind); +} + function asSkinKind(raw: string | null | undefined, fallback: SkinKind): SkinKind { const k = String(raw || "").trim(); if (KNOWN_SKIN_KINDS.has(k)) return k as SkinKind; @@ -223,9 +229,21 @@ export default function LoreItemEditor({ [item.pickable_skins, pickedSkinId], ); const pickedIs3d = Boolean( - skinMode === "pick" && pickedSkin && isModel3dKind(asSkinKind(pickedSkin.kind, "item_3d")), + skinMode === "pick" && pickedSkin && isModelSkinKind(asSkinKind(pickedSkin.kind, "item_3d")), ); + /** The 3D kind and model the preview shows, or null for the flat texture. */ + const previewKind: SkinKind | null = pickedIs3d + ? asSkinKind(pickedSkin!.kind, "item_3d") + : skinMode === "upload" && allows3d && use3d && modelFile + ? threeDKind || "item_3d" + : null; + const previewModel = pickedIs3d + ? previewModelFile + : skinMode === "upload" && allows3d && use3d && modelFile + ? modelFile + : null; + const isDirty = useMemo(() => { if (displayName.trim() !== baseline.displayName.trim()) return true; if (lore.length !== baseline.lore.length) return true; @@ -398,7 +416,7 @@ export default function LoreItemEditor({ } const file = await fetchAsFile(url, filename); let modelFile: File | null = null; - if (skinMode === "pick" && pickedSkin && isModel3dKind(asSkinKind(pickedSkin.kind, "item_3d"))) { + if (skinMode === "pick" && pickedSkin && isModelSkinKind(asSkinKind(pickedSkin.kind, "item_3d"))) { modelFile = await fetchAsFile( loreItemSkinModelUrl(pickedSkinId, item.base_set), `${pickedSkinId.trim()}.json`, @@ -804,7 +822,7 @@ export default function LoreItemEditor({ {skin.display_name || skin.id} - {isModel3dKind(asSkinKind(skin.kind, "handheld")) ? ( + {isModelSkinKind(asSkinKind(skin.kind, "handheld")) ? ( 3D ) : null} {skin.staff ? staff : null} @@ -848,20 +866,9 @@ export default function LoreItemEditor({ : "Default skin"}

diff --git a/frontend/app/components/profile/SkinShowcase.tsx b/frontend/app/components/profile/SkinShowcase.tsx index 5b989935..aa8a2947 100644 --- a/frontend/app/components/profile/SkinShowcase.tsx +++ b/frontend/app/components/profile/SkinShowcase.tsx @@ -131,6 +131,7 @@ function ArmourView({ row, files, fallback, children }: ViewProps) { helmet3d={Boolean(file("helmet_model") && file("helmet_texture"))} helmetModelFile={file("helmet_model")} helmetTextureFile={file("helmet_texture")} + helmetTextureMcmetaFile={file("helmet_texture_mcmeta")} /> ) : ( diff --git a/frontend/app/components/skins/ArmorPreview.tsx b/frontend/app/components/skins/ArmorPreview.tsx index 24478e1c..1311b9cb 100644 --- a/frontend/app/components/skins/ArmorPreview.tsx +++ b/frontend/app/components/skins/ArmorPreview.tsx @@ -11,6 +11,7 @@ import { loadTextureFromFile, parseJavaModelJson, } from "../../../lib/skins/javaModel"; +import { loadAnimatedFrames } from "../../../lib/skins/animatedTexture"; import { applySteveArmPose, attachSteveArmorOverlay, @@ -28,6 +29,8 @@ type Props = { helmet3d?: boolean; helmetModelFile?: File | null; helmetTextureFile?: File | null; + /** Optional `.mcmeta` for the 3D helmet texture; the preview shows its first frame. */ + helmetTextureMcmetaFile?: File | null; /** Fills its parent on a transparent background, without the editor's label and skin controls. */ stage?: boolean; className?: string; @@ -44,6 +47,7 @@ export default function ArmorPreview({ helmet3d = false, helmetModelFile = null, helmetTextureFile = null, + helmetTextureMcmetaFile = null, stage = false, className = "", }: Props) { @@ -132,7 +136,15 @@ export default function ArmorPreview({ const modelText = await helmetModelFile.text(); if (cancelled) return; const json = parseJavaModelJson(modelText); - const loaded = await loadTextureFromFile(helmetTextureFile); + let loaded: { texture: THREE.Texture; width: number; height: number }; + if (helmetTextureMcmetaFile) { + // Animated: the model's UVs cover one frame, so show the first one. + const animated = await loadAnimatedFrames(helmetTextureFile, helmetTextureMcmetaFile); + const canvas = animated.frames[animated.schedule[0]!.index]!; + loaded = { texture: new THREE.Texture(canvas), width: canvas.width, height: canvas.height }; + } else { + loaded = await loadTextureFromFile(helmetTextureFile); + } helmetItemTex = loaded.texture; if (cancelled) { helmetItemTex.dispose(); @@ -223,7 +235,17 @@ export default function ArmorPreview({ renderer?.dispose(); host.replaceChildren(); }; - }, [layer1File, layer2File, helmet3d, helmetModelFile, helmetTextureFile, playerSkinFile, use3dHelm, hasLayers]); + }, [ + layer1File, + layer2File, + helmet3d, + helmetModelFile, + helmetTextureFile, + helmetTextureMcmetaFile, + playerSkinFile, + use3dHelm, + hasLayers, + ]); if (!hasLayers) { return

Upload a layer to preview.

; diff --git a/frontend/app/components/skins/UploadForm.test.tsx b/frontend/app/components/skins/UploadForm.test.tsx index 1f01cc47..2b635fb5 100644 --- a/frontend/app/components/skins/UploadForm.test.tsx +++ b/frontend/app/components/skins/UploadForm.test.tsx @@ -88,4 +88,14 @@ describe("UploadForm item-first flow", () => { fireEvent.click(within(types).getByRole("button", { name: /Heavy Steel/ })); expect(screen.getAllByText(/^(Light|Heavy) Steel$/, { selector: "span" }).length).toBe(2); }); + + it("re-picks the style when rank perks change after the item was chosen", () => { + const view = render( undefined} />); + fireEvent.click(screen.getByRole("radio", { name: /^Steel/ })); + expect(screen.getByRole("group", { name: "Set types" })).toBeTruthy(); + view.rerender( undefined} />); + expect(screen.queryByRole("group", { name: "Set types" })).toBeNull(); + view.rerender( undefined} />); + expect(screen.getByRole("group", { name: "Set types" })).toBeTruthy(); + }); }); diff --git a/frontend/app/components/skins/UploadForm.tsx b/frontend/app/components/skins/UploadForm.tsx index 95160850..8634fd68 100644 --- a/frontend/app/components/skins/UploadForm.tsx +++ b/frontend/app/components/skins/UploadForm.tsx @@ -294,8 +294,24 @@ export default function UploadForm({ setScroll(""); } + // Rank perks can arrive after a tile was picked: re-pick the style if the chosen one is + // no longer allowed, or one became allowed (adjusting state during render, not in an effect). + const kindsKey = resolvedKinds == null ? "*" : resolvedKinds.join(","); + const [seenKindsKey, setSeenKindsKey] = useState(kindsKey); + if (kindsKey !== seenKindsKey) { + setSeenKindsKey(kindsKey); + if (target && (!kind || !kindAllowed(kind, resolvedKinds))) { + const next = firstAllowedKind(target, resolvedKinds); + if (next !== kind) { + setKind(next); + resetUploads(); + } + } + } + function selectTarget(next: SkinTarget) { - if (sameTarget(next, target)) return; + // Re-clicking the chosen tile only matters when it has no usable style yet. + if (sameTarget(next, target) && kind) return; setTarget(next); setKind(firstAllowedKind(next, resolvedKinds)); setTiers(next.type === "single" ? [newTier(next.set)] : []); @@ -952,6 +968,7 @@ export default function UploadForm({ helmet3d={previewTierEntry.helmet3d} helmetModelFile={previewTierEntry.files.helmet_model ?? null} helmetTextureFile={previewTierEntry.files.helmet_texture ?? null} + helmetTextureMcmetaFile={previewTierEntry.files[mcmetaField("helmet_texture")] ?? null} />
) : null} diff --git a/frontend/lib/skins/ownerFiles.test.ts b/frontend/lib/skins/ownerFiles.test.ts index 11a3e16c..f4f07622 100644 --- a/frontend/lib/skins/ownerFiles.test.ts +++ b/frontend/lib/skins/ownerFiles.test.ts @@ -63,6 +63,6 @@ describe("ownerFileNames", () => { slug: "x", tiers: ["iron"], }), - ).toEqual({}); + ).toEqual({ iron_helmet_texture_mcmeta: "iron_helmet_texture.png.mcmeta" }); }); }); diff --git a/frontend/lib/skins/ownerFiles.ts b/frontend/lib/skins/ownerFiles.ts index e87cd0ff..012cd907 100644 --- a/frontend/lib/skins/ownerFiles.ts +++ b/frontend/lib/skins/ownerFiles.ts @@ -66,15 +66,17 @@ export function ownerFileNames(row: Pick.mcmeta`), keyed `_mcmeta`. - * Armour is left out: its showcase draws the worn layers, which never animate. + * For armour only the 3D helmet texture is fetched: the showcase draws the worn layers, + * which never animate, and the helmet model needs its first frame. */ export function ownerAnimationFileNames( row: Pick, ): Record { - if (row.kind === "armor_set") return {}; const names: Record = {}; for (const [field, name] of Object.entries(ownerFileNames(row))) { - if (!isModelField(field) && name.endsWith(".png")) names[mcmetaField(field)] = `${name}.mcmeta`; + if (isModelField(field) || !name.endsWith(".png")) continue; + if (row.kind === "armor_set" && !field.endsWith("_helmet_texture")) continue; + names[mcmetaField(field)] = `${name}.mcmeta`; } return names; } diff --git a/frontend/lib/skins/sizes.test.ts b/frontend/lib/skins/sizes.test.ts index 27a0a909..8df1d053 100644 --- a/frontend/lib/skins/sizes.test.ts +++ b/frontend/lib/skins/sizes.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + assert3dPairBudgets, assertRanged3dBudget, expectedSizeForField, fileFieldsForKind, @@ -20,13 +21,13 @@ describe("pairBudgetHint", () => { }); it("shows whole kilobytes, rounded down", () => { - expect(pairBudgetHint(262_144)).toBe("Model + texture: max 256 KB"); - expect(pairBudgetHint(20_000)).toBe("Model + texture: max 19 KB"); + expect(pairBudgetHint(262_144)).toBe("Model + texture (and its .mcmeta): max 256 KB"); + expect(pairBudgetHint(20_000)).toBe("Model + texture (and its .mcmeta): max 19 KB"); }); it("keeps one decimal under 10 KB and bytes under 1 KB", () => { - expect(pairBudgetHint(5_000)).toBe("Model + texture: max 4.8 KB"); - expect(pairBudgetHint(800)).toBe("Model + texture: max 800 bytes"); + expect(pairBudgetHint(5_000)).toBe("Model + texture (and its .mcmeta): max 4.8 KB"); + expect(pairBudgetHint(800)).toBe("Model + texture (and its .mcmeta): max 800 bytes"); }); }); @@ -78,3 +79,30 @@ describe("animation slots", () => { expect(mcmetaField("unsigned")).toBe("unsigned_mcmeta"); }); }); + +describe("3D pair budgets", () => { + it("counts the texture's .mcmeta toward its pair", () => { + const pair = { texture: fakeFile(500), model: fakeFile(400) }; + expect(() => assert3dPairBudgets("item_3d", pair, 1000)).not.toThrow(); + expect(() => assert3dPairBudgets("item_3d", { ...pair, texture_mcmeta: fakeFile(200) }, 1000)).toThrow("1100"); + expect(() => + assert3dPairBudgets( + "gun", + { texture: fakeFile(500), carry_model: fakeFile(400), texture_mcmeta: fakeFile(200) }, + 1000, + ), + ).toThrow("gun/carry_model"); + expect(() => + assert3dPairBudgets( + "armor_set", + { + light_iron_helmet_texture: fakeFile(500), + light_iron_helmet_model: fakeFile(400), + light_iron_helmet_texture_mcmeta: fakeFile(200), + }, + 1000, + ["light_iron"], + ), + ).toThrow("light_iron_helmet"); + }); +}); diff --git a/frontend/lib/skins/sizes.ts b/frontend/lib/skins/sizes.ts index 826014cd..14cab7c3 100644 --- a/frontend/lib/skins/sizes.ts +++ b/frontend/lib/skins/sizes.ts @@ -169,8 +169,15 @@ export async function assertFileSize(file: File, expected: Size, label: string): } } -function assertPairBytes(texture: File | undefined, model: File | undefined, maxBytes: number, label: string): void { - const total = (texture?.size ?? 0) + (model?.size ?? 0); +/** A texture's optional `.mcmeta` counts toward its pair (as backend size_limits.py). */ +function assertPairBytes( + texture: File | undefined, + model: File | undefined, + maxBytes: number, + label: string, + mcmeta?: File, +): void { + const total = (texture?.size ?? 0) + (model?.size ?? 0) + (mcmeta?.size ?? 0); const cap = Math.max(0, Math.floor(maxBytes)); if (total > cap) { throw new Error(`${label}: texture + model is ${total} bytes (limit ${cap} bytes)`); @@ -198,13 +205,13 @@ export function assert3dPairBudgets( } if (isModel3dKind(kind)) { - assertPairBytes(files.texture, files.model, cap, kind); + assertPairBytes(files.texture, files.model, cap, kind, files.texture_mcmeta); return; } if (isGunKind(kind)) { for (const stem of ["carry_model", "reload_model", "aim_model"] as const) { - assertPairBytes(files.texture, files[stem], cap, `gun/${stem}`); + assertPairBytes(files.texture, files[stem], cap, `gun/${stem}`, files.texture_mcmeta); } return; } @@ -213,7 +220,13 @@ export function assert3dPairBudgets( for (const tier of helmet3dTiers) { const t = (tier || "").trim(); if (!t) continue; - assertPairBytes(files[`${t}_helmet_texture`], files[`${t}_helmet_model`], cap, `armor_set/${t}_helmet`); + assertPairBytes( + files[`${t}_helmet_texture`], + files[`${t}_helmet_model`], + cap, + `armor_set/${t}_helmet`, + files[`${t}_helmet_texture_mcmeta`], + ); } } } @@ -223,8 +236,8 @@ export function pairBudgetHint(maxBytes: number | undefined): string { if (!maxBytes || maxBytes <= 0) return ""; // Round down so the hint never overstates the limit. const kb = Math.floor((maxBytes / 1024) * 10) / 10; - if (kb < 1) return `Model + texture: max ${maxBytes} bytes`; - return `Model + texture: max ${kb >= 10 ? Math.floor(kb) : kb} KB`; + if (kb < 1) return `Model + texture (and its .mcmeta): max ${maxBytes} bytes`; + return `Model + texture (and its .mcmeta): max ${kb >= 10 ? Math.floor(kb) : kb} KB`; } /** Upload fields that take a model JSON rather than a PNG. */ diff --git a/frontend/lib/textureAnimation.test.ts b/frontend/lib/textureAnimation.test.ts index f066e07f..10cb958b 100644 --- a/frontend/lib/textureAnimation.test.ts +++ b/frontend/lib/textureAnimation.test.ts @@ -61,7 +61,20 @@ describe("parseTextureAnimation", () => { expect(() => parseTextureAnimation("{}")).toThrow('"animation"'); expect(() => parseTextureAnimation('{"animation":{"frametime":0}}')).toThrow("frametime"); expect(() => parseTextureAnimation('{"animation":{"frames":[-1]}}')).toThrow("frame"); - expect(() => parseTextureAnimation('{"animation":{"width":1.5}}')).toThrow("width"); + expect(() => parseTextureAnimation('{"animation":{"width":1.5}}')).toThrow("whole numbers"); + }); + + it("is as strict as the skins API", () => { + expect(() => parseTextureAnimation("")).toThrow("empty"); + expect(() => parseTextureAnimation('{"animation":{},"villager":{}}')).toThrow('only an "animation"'); + expect(() => parseTextureAnimation('{"animation":{"speed":2}}')).toThrow("unsupported keys: speed"); + expect(() => parseTextureAnimation('{"animation":{"frames":[{"time":2}]}}')).toThrow("index"); + expect(() => parseTextureAnimation('{"animation":{"frames":[{"index":0,"loop":1}]}}')).toThrow("index"); + expect(() => parseTextureAnimation('{"animation":{"frametime":2.0}}')).toThrow("whole numbers"); + expect(() => parseTextureAnimation('{"animation":{"frames":[1e1]}}')).toThrow("whole numbers"); + expect(parseTextureAnimation('\uFEFF{"animation":{"frames":[0,{"index":1,"time":3}]}}')).toEqual({ + animation: { frames: [0, { index: 1, time: 3 }] }, + }); }); }); diff --git a/frontend/lib/textureAnimation.ts b/frontend/lib/textureAnimation.ts index 9428019e..d81c1b38 100644 --- a/frontend/lib/textureAnimation.ts +++ b/frontend/lib/textureAnimation.ts @@ -1,3 +1,5 @@ +import limits from "../../shared/skins/base_sets.json"; + /** * Minecraft `.png.mcmeta` texture animation: frame geometry, timing and validation. * Shared by the wiki station viewer and the skin previews; mirrors the skins API rules. @@ -23,7 +25,18 @@ export type ScheduledFrame = { index: number; duration: number }; export const TICK_MS = 50; /** Largest `.mcmeta` the skins API accepts (shared limits.mcmeta_max_bytes). */ -export const MCMETA_MAX_BYTES = 2048; +export const MCMETA_MAX_BYTES: number = limits.limits.mcmeta_max_bytes; + +const ANIMATION_KEYS = new Set(["frametime", "interpolate", "width", "height", "frames"]); + +/** + * Every number an `.mcmeta` may hold is a whole number, so any number written with a + * decimal point or exponent (`1.0`, `2e1`) is rejected, as the API's JSON parser would. + */ +function hasNonIntegerNumber(text: string): boolean { + const outsideStrings = text.replace(/"(?:[^"\\]|\\.)*"/g, '""'); + return /\d\s*[.eE]|\.\d/.test(outsideStrings); +} /** * Frame size as Minecraft resolves it: both width and height given → that; only one → @@ -130,20 +143,35 @@ function isIndex(value: unknown): value is number { /** Parse and check `.mcmeta` text; throws with a player-facing message. */ export function parseTextureAnimation(text: string, label = "Animation"): TextureAnimationMetadata { + const body = text.replace(/^\uFEFF/, ""); + if (!body.trim()) throw new Error(`${label}: .mcmeta file is empty`); let raw: unknown; try { - raw = JSON.parse(text); + raw = JSON.parse(body); } catch { throw new Error(`${label}: .mcmeta is not valid JSON`); } if (!raw || typeof raw !== "object" || Array.isArray(raw)) { throw new Error(`${label}: .mcmeta must be a JSON object`); } + const keys = Object.keys(raw); + if (keys.length !== 1 || keys[0] !== "animation") { + throw new Error(`${label}: .mcmeta must be an object with only an "animation" section`); + } const animation = (raw as Record).animation; if (!animation || typeof animation !== "object" || Array.isArray(animation)) { throw new Error(`${label}: .mcmeta needs an "animation" object`); } const a = animation as Record; + const unknown = Object.keys(a) + .filter((key) => !ANIMATION_KEYS.has(key)) + .sort(); + if (unknown.length) { + throw new Error(`${label}: .mcmeta animation has unsupported keys: ${unknown.join(", ")}`); + } + if (hasNonIntegerNumber(body)) { + throw new Error(`${label}: .mcmeta numbers must be whole numbers (no decimals)`); + } const out: TextureAnimationMetadata["animation"] = {}; if (a.frametime !== undefined) { if (!isPositiveInt(a.frametime)) throw new Error(`${label}: frametime must be a whole number of ticks, 1 or more`); @@ -167,7 +195,8 @@ export function parseTextureAnimation(text: string, label = "Animation"): Textur if (isIndex(frame)) return frame; if (frame && typeof frame === "object" && !Array.isArray(frame)) { const f = frame as Record; - if (isIndex(f.index) && (f.time === undefined || isPositiveInt(f.time))) { + const extra = Object.keys(f).some((key) => key !== "index" && key !== "time"); + if (!extra && isIndex(f.index) && (f.time === undefined || isPositiveInt(f.time))) { return f.time === undefined ? { index: f.index } : { index: f.index, time: f.time }; } } From 77cbb9131a07aead28a74656f216b32ee94a8c42 Mon Sep 17 00:00:00 2001 From: Drefvelin <54400154+Drefvelin@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:06:38 +0000 Subject: [PATCH 3/3] Show a blank animation frame as an empty item instead of failing the preview Co-Authored-By: Claude Opus 5.5 --- frontend/app/components/skins/ModelPreview.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/app/components/skins/ModelPreview.tsx b/frontend/app/components/skins/ModelPreview.tsx index 820c2e19..80cac714 100644 --- a/frontend/app/components/skins/ModelPreview.tsx +++ b/frontend/app/components/skins/ModelPreview.tsx @@ -553,9 +553,15 @@ export default function ModelPreview({ root.name = "animatedItem"; const textures = animated.frames.map(textureFromCanvas); const frameRoots = animated.frames.map((canvas, i) => { - const frameRoot = buildExtrudedItemGroup(canvasImageData(canvas), textures[i]!, { - center: !onMannequin, - }); + let frameRoot: THREE.Object3D; + try { + frameRoot = buildExtrudedItemGroup(canvasImageData(canvas), textures[i]!, { + center: !onMannequin, + }); + } catch { + // A blank frame is valid (blinks, flashes): the item disappears for it. + frameRoot = new THREE.Group(); + } frameRoot.visible = false; root.add(frameRoot); return frameRoot;