Ctrl K
ComunidadGuías…

scripts/gen-carved-bodies.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * Bakes every festival pack into `shared/carved-bodies.ts`. Run with `pnpm gen:carved`.
 *
 * Each pack's drawings live in `scripts/mascot-bodies/<pack>.ts`, in a 240-unit square
 * centred on x = 120.
 *
 * Loaded by `node --experimental-strip-types`, so only erasable type syntax.
 */

import { writeFileSync } from "node:fs"
import { fileURLToPath } from "node:url"

import { toPath } from "./mascot-bodies/draw.ts"
import type { Def, Eyes, Fill } from "./mascot-bodies/carved.ts"
import { DIWALI } from "./mascot-bodies/diwali.ts"
import { HALLOWEEN } from "./mascot-bodies/halloween.ts"

/** Picker order: each pack's ids, in its drawing order. */
const PACKS: ReadonlyArray<[string, Def[]]> = [["halloween", HALLOWEEN], ["diwali", DIWALI]]
const DEFS = PACKS.flatMap(([, defs]) => defs)

const quote = (s: string) => JSON.stringify(s)

interface Baked {
  id: string
  name: string
  paint: Def["paint"]
  bone: string | null
  eyes: Eyes
  clip: string
  parts: Array<{ fill: Fill; d: string }>
}

const BAKED: Baked[] = DEFS.map((def) => ({
  id: def.id,
  name: def.name,
  paint: def.paint,
  bone: def.bone,
  eyes: { ...def.eyes, mouth: def.eyes.mouth ?? true, mouthInk: def.eyes.mouthInk ?? def.eyes.ink, mouthY: def.eyes.mouthY ?? null },
  clip: toPath(def.face),
  parts: def.parts.map(([field, fill]) => ({ fill, d: toPath(field) })),
}))

/** Long path data on several lines; both phone parsers read newlines as separators. */
function wrap(d: string, pad: string, width = 100): string {
  const lines: string[] = []
  let line = ""
  for (const token of d.split(" ")) {
    if (line && line.length + 1 + token.length > width) {
      lines.push(pad + line)
      line = token
    } else line = line ? `${line} ${token}` : token
  }
  if (line) lines.push(pad + line)
  return lines.join("\n")
}

function emitTs(): string {
  const entries = BAKED.map((body) => {
    const parts = body.parts.map((part) => `      { fill: ${quote(part.fill)}, d: ${quote(part.d)} },`)
    return [
      `  ${body.id}: {`,
      `    id: ${quote(body.id)},`,
      `    name: ${quote(body.name)},`,
      `    paint: ${quote(body.paint)},`,
      `    bone: ${body.bone ? quote(body.bone) : "null"},`,
      `    eyes: ${JSON.stringify(body.eyes).replace(/"(\w+)":/g, "$1: ").replace(/,(?=\w)/g, ", ")},`,
      `    clip: ${quote(body.clip)},`,
      `    parts: [`,
      ...parts,
      `    ],`,
      `  },`,
    ].join("\n")
  })
  const packs = PACKS.map(([pack, defs]) => {
    const upper = pack.toUpperCase()
    const type = pack[0].toUpperCase() + pack.slice(1)
    return [
      `/** Every ${type} body id, in the order the picker shows them. */`,
      `export const ${upper}_BODY_IDS = [${defs.map((def) => quote(def.id)).join(", ")}] as const;`,
      ``,
      `export type ${type}BodyId = (typeof ${upper}_BODY_IDS)[number];`,
    ].join("\n")
  })
  const all = PACKS.map(([pack]) => `...${pack.toUpperCase()}_BODY_IDS`).join(", ")
  return `/**
 * The festival bodies a bot can wear, drawn flat with a carved face.
 *
 * GENERATED FILE — do not hand-edit. Run \`pnpm gen:carved\` to rebuild it from
 * \`scripts/gen-carved-bodies.ts\`.
 *
 * Drawn flat: the bot's own colour (or a pale or fixed stand-in), a few fixed-colour accents
 * and a carved face. Paths are in a 240-unit square centred on x = 120.
 */

${packs.join("\n\n")}

export const CARVED_BODY_IDS = [${all}] as const;

export type CarvedBodyId = (typeof CARVED_BODY_IDS)[number];

export interface CarvedEyes {
  /** Rest position of the pair's centre. */
  cx: number;
  cy: number;
  /** Half the distance between the two eyes. */
  dx: number;
  /** Resting pill size. */
  w: number;
  h: number;
  /** Diameter when startled. */
  round: number;
  ink: string;
  /** False when the drawing carries its own mouth. */
  mouth: boolean;
  mouthInk: string;
  /** Where the smile sits when it can't hang under the eyes. */
  mouthY: number | null;
}

export interface CarvedBody {
  id: CarvedBodyId;
  /** Human-readable name, used for the picker and the accessible label. */
  name: string;
  /** "bot" fills with the bot's colour, "pale" with a near-white tint of it, "bone" with \`bone\`. */
  paint: "bot" | "pale" | "bone";
  bone: string | null;
  eyes: CarvedEyes;
  /** Where the eyes may show. */
  clip: string;
  /** Draw order, bottom first. "body" is the paint; "accent" is the bot's colour. */
  parts: ReadonlyArray<{ fill: string; d: string }>;
}

export const CARVED_BODIES: Record<CarvedBodyId, CarvedBody> = {
${entries.join("\n")}
};
`
}

const swiftNumber = (n: number | null) => (n === null ? "nil" : String(n))

function emitSwift(): string {
  const entries = BAKED.map((body) => {
    const e = body.eyes
    const parts = body.parts.map((part) => [
      `                Part(fill: ${quote(part.fill)}, path: """`,
      wrap(part.d, "                "),
      `                """),`,
    ].join("\n"))
    return [
      `        ${quote(body.id)}: Body(`,
      `            id: ${quote(body.id)},`,
      `            name: ${quote(body.name)},`,
      `            paint: ${quote(body.paint)},`,
      `            bone: ${body.bone ? quote(body.bone) : "nil"},`,
      `            eyes: Eyes(cx: ${e.cx}, cy: ${e.cy}, dx: ${e.dx}, w: ${e.w}, h: ${e.h}, round: ${e.round}, ink: ${quote(e.ink)}, mouth: ${e.mouth}, mouthInk: ${quote(e.mouthInk)}, mouthY: ${swiftNumber(e.mouthY)}),`,
      `            clip: """`,
      wrap(body.clip, "            "),
      `            """,`,
      `            parts: [`,
      ...parts,
      `            ]`,
      `        ),`,
    ].join("\n")
  })
  return `// The festival bodies a bot can wear — the phone's half of \`shared/carved-bodies.ts\`.
//
// GENERATED FILE — do not hand-edit. Run \`pnpm gen:carved --swift <file>\` to rebuild it
// from \`scripts/gen-carved-bodies.ts\`.
//
// Paths are absolute \`M\`, \`C\`, \`Z\` only, in a 240-unit square centred on x = 120, with
// newlines as separators — what \`MausSilhouette.parse\` reads.
import CoreGraphics

enum CarvedBodies {
    struct Eyes {
        let cx: CGFloat
        let cy: CGFloat
        let dx: CGFloat
        let w: CGFloat
        let h: CGFloat
        let round: CGFloat
        let ink: String
        let mouth: Bool
        let mouthInk: String
        let mouthY: CGFloat?
    }

    /// \`fill\` is "body" (the bot's paint), "accent" (the bot's colour) or a literal hex colour.
    struct Part {
        let fill: String
        let path: String
    }

    struct Body {
        let id: String
        let name: String
        /// "bot", "pale" or "bone".
        let paint: String
        let bone: String?
        let eyes: Eyes
        let clip: String
        let parts: [Part]
    }

${PACKS.map(([pack, defs]) => `    static let ${pack}: [String] = [${defs.map((def) => quote(def.id)).join(", ")}]`).join("\n")}

    static let all: [String: Body] = [
${entries.join("\n")}
    ]

    static func body(_ id: String?) -> Body? {
        guard let id else { return nil }
        return all[id]
    }
}
`
}

const kotlinFloat = (n: number) => `${n}f`

function emitKotlin(): string {
  const entries = BAKED.map((body) => {
    const e = body.eyes
    const parts = body.parts.map((part) => [
      `                Part(fill = ${quote(part.fill)}, path = """`,
      wrap(part.d, "                "),
      `                """),`,
    ].join("\n"))
    return [
      `        ${quote(body.id)} to Body(`,
      `            id = ${quote(body.id)},`,
      `            name = ${quote(body.name)},`,
      `            paint = ${quote(body.paint)},`,
      `            bone = ${body.bone ? quote(body.bone) : "null"},`,
      `            eyes = Eyes(cx = ${kotlinFloat(e.cx)}, cy = ${kotlinFloat(e.cy)}, dx = ${kotlinFloat(e.dx)}, w = ${kotlinFloat(e.w)}, h = ${kotlinFloat(e.h)}, round = ${kotlinFloat(e.round)}, ink = ${quote(e.ink)}, mouth = ${e.mouth}, mouthInk = ${quote(e.mouthInk)}, mouthY = ${e.mouthY === null ? "null" : kotlinFloat(e.mouthY)}),`,
      `            clip = """`,
      wrap(body.clip, "            "),
      `            """,`,
      `            parts = listOf(`,
      ...parts,
      `            ),`,
      `        ),`,
    ].join("\n")
  })
  return `package com.openmausbot.companion.ui

/**
 * The festival bodies a bot can wear — the Android half of \`shared/carved-bodies.ts\`.
 *
 * GENERATED FILE — do not hand-edit. Run \`pnpm gen:carved --kotlin <file>\` to rebuild it
 * from \`scripts/gen-carved-bodies.ts\`.
 *
 * Paths are absolute \`M\`, \`C\`, \`Z\` only, in a 240-unit square centred on x = 120, with
 * newlines as separators — what [MausSilhouette] parses.
 */
internal object CarvedBodies {
    data class Eyes(
        val cx: Float,
        val cy: Float,
        val dx: Float,
        val w: Float,
        val h: Float,
        val round: Float,
        val ink: String,
        val mouth: Boolean,
        val mouthInk: String,
        val mouthY: Float?,
    )

    /** [fill] is "body" (the bot's paint), "accent" (the bot's colour) or a literal hex colour. */
    data class Part(val fill: String, val path: String)

    data class Body(
        val id: String,
        val name: String,
        /** "bot", "pale" or "bone". */
        val paint: String,
        val bone: String?,
        val eyes: Eyes,
        val clip: String,
        val parts: List<Part>,
    )

${PACKS.map(([pack, defs]) => `    val ${pack}: List<String> = listOf(${defs.map((def) => quote(def.id)).join(", ")})`).join("\n")}

    val all: Map<String, Body> = mapOf(
${entries.join("\n")}
    )

    fun body(id: String?): Body? = id?.let { all[it] }
}
`
}

const out = fileURLToPath(new URL("../shared/carved-bodies.ts", import.meta.url))
writeFileSync(out, emitTs())
console.log(`wrote ${BAKED.length} carved bodies to shared/carved-bodies.ts`)

for (const [flag, emitNative] of [["--swift", emitSwift], ["--kotlin", emitKotlin]] as const) {
  const at = process.argv.indexOf(flag)
  if (at === -1) continue
  const nativeOut = process.argv[at + 1]
  if (!nativeOut) throw new Error(`${flag} needs an output file`)
  writeFileSync(nativeOut, emitNative())
  console.log(`wrote ${nativeOut}`)
}