diff --git a/.gitignore b/.gitignore index 522d7b5..82434ef 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,6 @@ dist-ssr *.sw? .env -BearMetal/ \ No newline at end of file +BearMetal/ +resources/ +!**/*/resources/ \ No newline at end of file diff --git a/deno.json b/deno.json index 033c047..937d7ed 100644 --- a/deno.json +++ b/deno.json @@ -20,9 +20,11 @@ "@cgg/sockpuppet/client": "../sockpuppet.ts/client/mod.ts", "@deno/vite-plugin": "npm:@deno/vite-plugin@^1.0.0", "@preact/preset-vite": "npm:@preact/preset-vite@^2.9.1", + "@std/encoding": "jsr:@std/encoding@^1.0.5", "@std/fs": "jsr:@std/fs@^1.0.4", "@std/http": "jsr:@std/http@^1.0.8", "@std/path": "jsr:@std/path@^1.0.6", + "@zip.js/zip.js": "npm:@zip.js/zip.js@^2.7.52", "autoprefixer": "npm:autoprefixer@^10.4.20", "babel-plugin-transform-hook-names": "npm:babel-plugin-transform-hook-names@^1.0.2", "jotai": "npm:jotai@^2.10.1", diff --git a/deno.lock b/deno.lock index 94557e9..8a9da87 100644 --- a/deno.lock +++ b/deno.lock @@ -16,6 +16,7 @@ "npm:@deno/vite-plugin@1": "1.0.0_vite@5.4.9", "npm:@preact/preset-vite@^2.9.1": "2.9.1_@babel+core@7.25.8_vite@5.4.9_preact@10.24.3", "npm:@types/node@*": "22.5.4", + "npm:@zip.js/zip.js@^2.7.52": "2.7.52", "npm:autoprefixer@^10.4.20": "10.4.20_postcss@8.4.47", "npm:babel-plugin-transform-hook-names@^1.0.2": "1.0.2_@babel+core@7.25.8", "npm:jotai@^2.10.1": "2.10.1", @@ -493,6 +494,9 @@ "undici-types" ] }, + "@zip.js/zip.js@2.7.52": { + "integrity": "sha512-+5g7FQswvrCHwYKNMd/KFxZSObctLSsQOgqBSi0LzwHo3li9Eh1w5cF5ndjQw9Zbr3ajVnd2+XyiX85gAetx1Q==" + }, "ansi-regex@5.0.1": { "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" }, @@ -1414,12 +1418,14 @@ "workspace": { "dependencies": [ "jsr:@bearmetal/store@^0.0.5", + "jsr:@std/encoding@^1.0.5", "jsr:@std/fs@^1.0.4", "jsr:@std/http@^1.0.8", "jsr:@std/path@^1.0.6", "npm:@babel/plugin-transform-react-jsx-development@^7.25.7", "npm:@deno/vite-plugin@1", "npm:@preact/preset-vite@^2.9.1", + "npm:@zip.js/zip.js@^2.7.52", "npm:autoprefixer@^10.4.20", "npm:babel-plugin-transform-hook-names@^1.0.2", "npm:jotai@^2.10.1", diff --git a/pack_versions/48.json b/pack_versions/48.json index b4e81fc..4aff649 100644 --- a/pack_versions/48.json +++ b/pack_versions/48.json @@ -1,5 +1,6 @@ { "version": "48", + "mcVersion": "^1.21", "schema": { "pack.mcmeta": "json", "pack.png": "image/png", diff --git a/server/main.ts b/server/main.ts index ecdf611..b612c96 100644 --- a/server/main.ts +++ b/server/main.ts @@ -5,6 +5,7 @@ import { ensureDir, ensureFile, exists } from "@std/fs"; import { Router } from "./router.ts"; import { getPackVersion } from "./util/packVersion.ts"; import { createTagRoutes } from "./tags/routes.ts"; +import { createResourcesRoutes } from "./resources/routes.ts"; const installPath = Deno.env.get("BMP_INSTALL_DIR") || "./"; @@ -223,6 +224,7 @@ router.route("/api/versions") }); createTagRoutes(router); +createResourcesRoutes(router); sockpuppet.addHandler((req: Request) => { if (new URL(req.url).pathname.startsWith("/api")) return; diff --git a/server/resources/readers.ts b/server/resources/readers.ts new file mode 100644 index 0000000..730046a --- /dev/null +++ b/server/resources/readers.ts @@ -0,0 +1,80 @@ +import { encodeBase64 } from "@std/encoding/base64"; +import { readDirFiles } from "../util/readDir.ts"; + +interface BlockItem { + name: string; + resourceLocation: string; + images: string[]; +} + +export const readBlocks = async (path: string) => { + const blocks: BlockItem[] = + (await readDirFiles(path + "/assets/minecraft/blockstates")) + .map((b) => ({ + name: b.replace(".json", ""), + resourceLocation: "minecraft:" + b.replace(".json", ""), + images: [], + })); + + const texPath = path + "/assets/minecraft/textures/block"; + for await (const image of Deno.readDir(texPath)) { + for (const block of blocks) { + if (image.name.startsWith(block.name)) { + const data = await Deno.readFile(texPath + "/" + image.name); + block.images.push("image/png;base64," + encodeBase64(data)); + } + } + } + return blocks; +}; + +export const readItems = async (path: string) => { + const items: BlockItem[] = + (await readDirFiles(path + "/assets/minecraft/models/item")).map((i) => ({ + name: i.replace(".json", ""), + resourceLocation: "minecraft:" + i.replace(".json", ""), + images: [], + })).slice(0, 10); + + for (const item of items) { + const data = await Deno.readFile( + path + "/assets/minecraft/models/item/" + item.name + ".json", + ); + const json = JSON.parse(new TextDecoder().decode(data)); + const texDir = path + "/assets/minecraft/textures/"; + if (json.textures) { + const texLoc = json.textures.layer0; + if (texLoc) { + const data = await Deno.readFile( + texDir + texLoc.replace("minecraft:", "") + ".png", + ); + item.images.push("image/png;base64," + encodeBase64(data)); + } + } else if (json.parent) { + const parent = await Deno.readFile( + path + "/assets/minecraft/models/" + + json.parent.replace("minecraft:", "") + ".json", + ); + const parentJson = JSON.parse(new TextDecoder().decode(parent)); + if (parentJson.textures) { + let texLoc = parentJson.textures.all; + if (!texLoc) texLoc = parentJson.textures.side; + if (!texLoc) texLoc = parentJson.textures.top; + if (!texLoc) texLoc = parentJson.textures.bottom; + if (texLoc) { + const data = await Deno.readFile( + texDir + texLoc.replace("minecraft:", "") + ".png", + ); + item.images.push("image/png;base64," + encodeBase64(data)); + } + } + } + } + + return items; +}; + +if (import.meta.main) { + const path = "./resources/1.21.1"; + console.log(await readItems(path)); +} diff --git a/server/resources/routes.ts b/server/resources/routes.ts new file mode 100644 index 0000000..c070b74 --- /dev/null +++ b/server/resources/routes.ts @@ -0,0 +1,37 @@ +import type { Router } from "../router.ts"; +import { readDirDirs } from "../util/readDir.ts"; +import { versionCompat } from "../util/versionCompat.ts"; +import { readBlocks } from "./readers.ts"; + +export const createResourcesRoutes = (router: Router) => { + router.route("/api/resources/:path*") + .get(async (req, ctx) => { + const path = ctx.params.path; + if (!path) { + return new Response("no path provided", { status: 400 }); + } + const format = ctx.url.searchParams.get("format"); + if (!format) { + return new Response("no format provided", { status: 400 }); + } + const packVersion = await Deno.readTextFile( + "./pack_versions/" + format + ".json", + ); + const packVersionJson = JSON.parse(packVersion); + const mcVersion = packVersionJson.mcVersion; + const resourceVersions = await readDirDirs("./resources"); + for (const resourceVersion of resourceVersions) { + if (versionCompat(resourceVersion, mcVersion)) { + const resourcePath = "./resources/" + resourceVersion; + const splitPath = path.split("/"); + switch (splitPath[0]) { + case "blocks": { + return new Response( + JSON.stringify(await readBlocks(resourcePath)), + ); + } + } + } + } + }); +}; diff --git a/server/resources/unzip.ts b/server/resources/unzip.ts new file mode 100644 index 0000000..bd126a5 --- /dev/null +++ b/server/resources/unzip.ts @@ -0,0 +1,58 @@ +import { BearMetalStore } from "@bearmetal/store"; +import { ZipReader } from "@zip.js/zip.js"; +import { ensureFile } from "@std/fs"; + +export async function unzipResources(mcVersion?: string) { + using store = new BearMetalStore(); + mcVersion = mcVersion || await currentVersion(store); + + console.log("mcVersion", mcVersion); + + if (!mcVersion) return; + + const blob = await Deno.open( + store.get("mcPath") + "/versions/" + mcVersion + "/" + mcVersion + ".jar", + ); + + const zip = new ZipReader(blob); + + for (const entry of await zip.getEntries()) { + if ( + entry.filename.startsWith("assets/") || entry.filename.startsWith("data/") + ) { + // console.log("entry", entry); + await ensureFile(`./resources/${mcVersion}/${entry.filename}`); + const writer = await Deno.open( + `./resources/${mcVersion}/${entry.filename}`, + { write: true }, + ); + await entry.getData?.(writer); + } + } +} + +async function currentVersion(store: BearMetalStore) { + const mcPath = store.get("mcPath"); + if (!mcPath) return; + + const versions = Array.from(Deno.readDirSync(mcPath + "/versions")).filter( + (d) => d.isDirectory, + ).map((d) => d.name).sort(); + let version = versions.pop(); + let found = false; + versionC: + while (!found) { + for await (const file of Deno.readDir(mcPath + "/versions/" + version)) { + if (file.name.endsWith(".jar")) { + found = true; + break versionC; + } + } + version = versions.pop(); + } + return version; +} + +if (import.meta.main) { + unzipResources(); +} diff --git a/server/tags/routes.ts b/server/tags/routes.ts index bbc2f0b..3787d41 100644 --- a/server/tags/routes.ts +++ b/server/tags/routes.ts @@ -70,12 +70,13 @@ export const createTagRoutes = (router: Router) => { const tagDir = await getTagDir(store, ctx.params.namespace, version); const tag = ctx.params.tag; - if (!tag) { + const type = ctx.params.type; + if (!tag || !type) { return new Response("no tag name provided", { status: 400 }); } try { - const tagFile = Deno.readTextFileSync(tagDir + "/" + tag + ".json"); + const tagFile = Deno.readTextFileSync(`${tagDir}/${type}/${tag}.json`); return new Response(tagFile, { status: 200 }); } catch { return new Response("no tag found", { status: 404 }); diff --git a/server/util/readDir.ts b/server/util/readDir.ts new file mode 100644 index 0000000..693d0d9 --- /dev/null +++ b/server/util/readDir.ts @@ -0,0 +1,20 @@ +export const readDirFiles = async (path: string) => { + return readDirFiltered(path, (file) => file.isFile); +}; + +export const readDirDirs = async (path: string) => { + return readDirFiltered(path, (file) => file.isDirectory); +}; + +export const readDirFiltered = async ( + path: string, + filter: (file: Deno.DirEntry) => boolean, +) => { + const files: string[] = []; + for await (const file of Deno.readDir(path)) { + if (filter(file)) { + files.push(file.name); + } + } + return files; +}; diff --git a/server/util/versionCompat.ts b/server/util/versionCompat.ts new file mode 100644 index 0000000..68469ed --- /dev/null +++ b/server/util/versionCompat.ts @@ -0,0 +1,14 @@ +export const versionCompat = (version: string, targetVersion: string) => { + if (targetVersion === "*") return true; + if (targetVersion === version) return true; + if (targetVersion.startsWith("^")) { + const versionSplit = version.split("."); + const targetVersionSplit = targetVersion.split("."); + for (let i = 0; i < versionSplit.length; i++) { + if (versionSplit[i] > targetVersionSplit[i]) { + return true; + } + } + } + return false; +}; diff --git a/src/atoms/namespace.ts b/src/atoms/namespace.ts index 518a128..216e4e0 100644 --- a/src/atoms/namespace.ts +++ b/src/atoms/namespace.ts @@ -1,4 +1,5 @@ import { atomWithStorage } from "jotai/utils"; +import { useAtom } from "jotai"; const key = "bmp:namespace"; // const namespaceAtomPrimitive = atom(localStorage.getItem(key) ??""); @@ -12,3 +13,7 @@ const key = "bmp:namespace"; // ) export const namespaceAtom = atomWithStorage(key, ""); + +export const useNamespace = () => { + return useAtom(namespaceAtom); +}; diff --git a/src/components/editor/tags/editor.tsx b/src/components/editor/tags/editor.tsx index 129cc5b..3761a6a 100644 --- a/src/components/editor/tags/editor.tsx +++ b/src/components/editor/tags/editor.tsx @@ -1,16 +1,17 @@ -import { Link, Route, Routes } from "react-router-dom"; +import { Link, Route, Routes, useParams } from "react-router-dom"; import useSWR from "swr"; import { fetchJson } from "../../../util/fetchJson.ts"; import { useAtom } from "jotai"; -import { namespaceAtom } from "../../../atoms/namespace.ts"; +import { namespaceAtom, useNamespace } from "../../../atoms/namespace.ts"; import { Loader } from "../../../components/loader.tsx"; -import { useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import { NewTagModal } from "./newTagModal.tsx"; -export const TagEditor = () => { +export const TagRouter = () => { return ( + ); }; @@ -51,3 +52,76 @@ function TagList() { ); } + +interface Tag { + replace: boolean; + values: (string | { id: string; required: boolean })[]; +} + +function TagEditor() { + const [namespace, _setNamespace] = useNamespace(); + const { typeTag } = useParams(); + const { data, isLoading } = useSWR( + `/api/pack/${namespace}/tags/${typeTag}`, + fetchJson, + ); + const [tag, setTag] = useState({ + replace: false, + values: [], + }); + + useEffect(() => { + if (isLoading) { + return; + } + setTag(data || { replace: false, values: [] }); + }, [data, isLoading]); + + if (isLoading) { + return ; + } + + return ( +
+

+ {typeTag}: + + setTag({ ...tag, replace: (e.target as any).checked })} + /> + {tag.values.map((value, i) => ( +
+ + setTag({ + ...tag, + values: tag.values.map((v, i) => + i === i ? e.target.value : v + ), + })} + /> + +
+ ))} + {tag.values.length === 0 && ( + + )} +

+
+ ); +} diff --git a/src/hooks/useStream.ts b/src/hooks/useStream.ts new file mode 100644 index 0000000..b979d38 --- /dev/null +++ b/src/hooks/useStream.ts @@ -0,0 +1,42 @@ +import { useEffect } from "preact/hooks"; + +export const useStream = ( + stream: ReadableStream, + onData: (data: T) => void, + onError: (err: Error) => void, +) => { + const reader = stream.getReader(); + const read = async () => { + const { done, value } = await reader.read(); + if (done) { + reader.releaseLock(); + return; + } + onData(value); + read(); + }; + read(); +}; + +export const useRemoteStream = ( + url: string, + onData: (data: T) => void, + onError: (err: Error) => void, +) => { + useEffect(() => { + const stream = new ReadableStream({ + async start(controller) { + const res = await fetch(url); + res.body?.pipeThrough(new TextDecoderStream()) + .pipeTo( + new WritableStream({ + write(chunk) { + controller.enqueue(chunk); + }, + }), + ); + }, + }); + useStream(stream, onData, onError); + }, [url]); +}; diff --git a/src/views/editor.tsx b/src/views/editor.tsx index 87ab6ed..9f8ea80 100644 --- a/src/views/editor.tsx +++ b/src/views/editor.tsx @@ -9,7 +9,7 @@ import { Outlet, Route, Routes } from "react-router-dom"; import { Selector } from "../components/editor/selector.tsx"; import { NamespaceModal } from "../components/editor/namespaceModal.tsx"; import { EditorWrapper } from "../components/editor/wrapper.tsx"; -import { TagEditor } from "../components/editor/tags/editor.tsx"; +import { TagRouter } from "../components/editor/tags/editor.tsx"; export const Editor = () => { const [packName, setPackName] = useState(""); @@ -125,7 +125,7 @@ export const Editor = () => { > } + element={} />