import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import sharp from "sharp"; import { createGameAssetIO } from "./lib/ktx2.mjs"; const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const sourcePath = path.join(repositoryRoot, "game_assets/models/claudecraft/chars/players/mage.glb"); const outputPath = path.join(repositoryRoot, "game_assets/textures/claudecraft/chars/players/priest-vestments.webp"); function clamp01(value) { return Math.max(0, Math.min(1, value)); } function luminance(red, green, blue) { return (red * 0.2126 + green * 0.7152 + blue * 0.0722) / 255; } function paintSwatch(pixels, width, channels, rectangle, shadow, highlight) { const samples = []; for (let y = rectangle.y; y < rectangle.y + rectangle.height; y += 1) { for (let x = rectangle.x; x < rectangle.x + rectangle.width; x += 1) { const offset = (y * width + x) * channels; if (pixels[offset + 3] === 0) continue; samples.push(luminance(pixels[offset], pixels[offset + 1], pixels[offset + 2])); } } const minimum = Math.min(...samples); const maximum = Math.max(...samples); const range = Math.max(0.001, maximum - minimum); for (let y = rectangle.y; y < rectangle.y + rectangle.height; y += 1) { for (let x = rectangle.x; x < rectangle.x + rectangle.width; x += 1) { const offset = (y * width + x) * channels; if (pixels[offset + 3] === 0) continue; const sourceLuminance = luminance(pixels[offset], pixels[offset + 1], pixels[offset + 2]); const mix = clamp01((sourceLuminance - minimum) / range); for (let channel = 0; channel < 3; channel += 1) { pixels[offset + channel] = Math.round(shadow[channel] + (highlight[channel] - shadow[channel]) * mix); } } } } const io = await createGameAssetIO(); const document = await io.read(sourcePath); const sourceTexture = document.getRoot().listTextures().find((texture) => texture.getName() === "mage_texture"); if (!sourceTexture?.getImage()) throw new Error("Mage source is missing mage_texture."); const decoded = await sharp(sourceTexture.getImage()).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); const { width, height, channels } = decoded.info; if (width !== 512 || height !== 512 || channels !== 4) { throw new Error(`Unexpected mage palette shape: ${width}x${height}x${channels}.`); } const pixels = new Uint8Array(decoded.data); paintSwatch(pixels, width, channels, { x: 0, y: 128, width: 128, height: 128 }, [48, 58, 94], [244, 239, 211]); paintSwatch(pixels, width, channels, { x: 128, y: 128, width: 64, height: 128 }, [105, 66, 18], [255, 218, 109]); paintSwatch(pixels, width, channels, { x: 64, y: 256, width: 64, height: 128 }, [105, 66, 18], [255, 218, 109]); paintSwatch(pixels, width, channels, { x: 128, y: 256, width: 64, height: 128 }, [37, 71, 101], [157, 215, 221]); await mkdir(path.dirname(outputPath), { recursive: true }); await writeFile(outputPath, await sharp(pixels, { raw: { width, height, channels } }) .webp({ quality: 92, smartSubsample: true }) .toBuffer()); console.log(`Built ${path.relative(repositoryRoot, outputPath)}`);