// ============================================================================ // devlog-data.jsx — shared devlog content + workshop draft persistence // Bilingual (EN/ES): per-language title/excerpt/body; shared tag/author/date/ // status/cover. Used by the public archive and the internal Note Workshop. // ============================================================================ const DEVLOG_TAGS = ["Patchwork", "Art", "Build", "Sound", "World"]; const DEVLOG_AUTHORS = ["Charlie", "Mabu"]; const DEVLOG_STATUSES = ["draft", "review", "published"]; // Tag display labels per language (canonical key stays English for filtering) const TAG_LABELS = { en: { Patchwork: "Patchwork", Art: "Art", Build: "Build", Sound: "Sound", World: "World" }, es: { Patchwork: "Remiendos", Art: "Arte", Build: "Construcción", Sound: "Sonido", World: "Mundo" }, }; function tagLabel(tag, lang) { return (TAG_LABELS[lang] && TAG_LABELS[lang][tag]) || tag; } // Canonical published posts — the three from the homepage, now bilingual. const CANON_POSTS = [ { id: "post-dungeon-memory", dateISO: "2026-05-12", date: "12 · 05 · 26", tag: "Patchwork", author: "Charlie", status: "published", thumb: "DEVLOG IMG · DUNGEON TILES", cover: null, i18n: { en: { title: "Stitching a dungeon that remembers you", excerpt: "On procedural dungeons that aren't actually procedural — how rooms carry grudges between runs, and why we let the map keep a diary.", body: "We never wanted a maze that resets politely every morning. A dungeon that forgets you is just furniture.\n\nSo the generator doesn't roll fresh each run. It carries a ledger. The room where you bled out keeps the stain. The corridor you sprinted down — twice, panicking — widens by a tile, as if the place learned the shape of your fear. We store a small grudge value per room and let it nudge the next layout.\n\nThe trick was keeping it legible. Players need to feel the callback without reading patch notes. So grudges surface as set dressing: a snuffed lantern here, a door that now opens the wrong way. Nothing mechanical you must learn. Just a place that, quietly, remembers.", }, es: { title: "Cosiendo una mazmorra que te recuerda", excerpt: "Sobre mazmorras procedurales que en realidad no lo son: cómo las salas guardan rencor entre partidas y por qué dejamos que el mapa lleve un diario.", body: "Nunca quisimos un laberinto que se reinicie con cortesía cada mañana. Una mazmorra que te olvida no es más que mobiliario.\n\nAsí que el generador no se rehace en cada partida. Lleva un registro. La sala donde te desangraste conserva la mancha. El pasillo por el que corriste —dos veces, presa del pánico— se ensancha una baldosa, como si el lugar hubiera aprendido la forma de tu miedo. Guardamos un pequeño valor de rencor por sala y dejamos que incline el siguiente trazado.\n\nLo difícil fue mantenerlo legible. El jugador debe sentir el guiño sin leer notas de parche. Por eso el rencor aflora como escenografía: un farol apagado aquí, una puerta que ahora abre al revés. Nada mecánico que debas aprender. Solo un lugar que, en voz baja, recuerda.", }, }, }, { id: "post-slime-frames", dateISO: "2026-05-03", date: "03 · 05 · 26", tag: "Art", author: "Mabu", status: "published", thumb: "DEVLOG IMG · SLIME ANIM", cover: null, i18n: { en: { title: "Sixty frames of one nervous slime", excerpt: "Mabu walks through the wobble-to-burst animation pipeline.", body: "A slime is the easiest enemy to draw and the hardest to animate. It has no skeleton to lie about. Every bit of character lives in how it wobbles.\n\nThis one needed to read as nervous — not threatening, just deeply unsure it should exist. I roughed sixty frames by hand: an idle jiggle that never quite settles, a flinch when you step close, and a burst that's more apology than attack.\n\nThe pipeline is unglamorous. Pencil pass, clean line, two-tone toon fill, then a wobble deform baked on top so the silhouette breathes between keyframes. Sixty frames for four seconds of one anxious blob. Worth it.", }, es: { title: "Sesenta fotogramas de un slime nervioso", excerpt: "Mabu repasa el proceso de animación, del temblor al estallido.", body: "Un slime es el enemigo más fácil de dibujar y el más difícil de animar. No tiene esqueleto sobre el que mentir. Todo su carácter vive en cómo se bambolea.\n\nEste tenía que leerse como nervioso: nada amenazante, solo profundamente inseguro de que debiera existir. Bocetée sesenta fotogramas a mano: un temblor en reposo que nunca acaba de asentarse, un respingo cuando te acercas y un estallido que es más disculpa que ataque.\n\nEl proceso no tiene glamur. Pasada a lápiz, línea limpia, relleno toon a dos tonos y, encima, una deformación de bamboleo para que la silueta respire entre fotogramas clave. Sesenta fotogramas para cuatro segundos de una bola ansiosa. Valió la pena.", }, }, }, { id: "post-sanity-stat", dateISO: "2026-04-21", date: "21 · 04 · 26", tag: "Build", author: "Charlie", status: "published", thumb: "DEVLOG IMG · SANITY UI", cover: null, i18n: { en: { title: "Sanity, the worst stat we've ever shipped", excerpt: "Lies, hallucinations, and the UI elements that fight back.", body: "Health is honest. It goes down, you understand why, you drink the red bottle. Sanity is a liar, and we built it to lie on purpose.\n\nAs your sanity drops, the UI stops being your ally. The minimap rotates a few degrees off-true. An item count reads 3 when you're holding 2. Eventually the health bar itself starts reporting numbers it shouldn't know.\n\nThis is a nightmare to test — every bug report could be a feature working as intended. So we added a hidden honesty channel in debug builds that logs what the UI claimed versus the truth. Shipping a stat whose whole job is to deceive the player taught us more about trust in interfaces than any other system.", }, es: { title: "La cordura, la peor estadística que hemos lanzado", excerpt: "Mentiras, alucinaciones y los elementos de interfaz que se resisten.", body: "La salud es honesta. Baja, entiendes por qué, bebes la botella roja. La cordura es una mentirosa, y la construimos para mentir a propósito.\n\nA medida que tu cordura cae, la interfaz deja de ser tu aliada. El minimapa gira unos grados fuera de su eje. Un recuento de objetos marca 3 cuando llevas 2. Con el tiempo, la propia barra de salud empieza a informar de números que no debería conocer.\n\nProbarlo es una pesadilla: cada informe de error podría ser una función trabajando como es debido. Así que añadimos un canal de honestidad oculto en las compilaciones de depuración que registra lo que la interfaz afirmó frente a la verdad. Lanzar una estadística cuyo único trabajo es engañar al jugador nos enseñó más sobre la confianza en las interfaces que cualquier otro sistema.", }, }, }, ]; // Pull localized fields, falling back to English when a translation is blank. function localized(post, lang) { const i = post.i18n || {}; const cur = i[lang] || {}; const en = i.en || {}; return { title: cur.title || en.title || "", excerpt: cur.excerpt || en.excerpt || "", body: cur.body || en.body || "", }; } // ---- Workshop draft persistence (localStorage) ---- const DRAFTS_KEY = "blackcraft.devlog.drafts"; function loadDrafts() { try { const raw = localStorage.getItem(DRAFTS_KEY); if (raw) return JSON.parse(raw); } catch (e) {} return null; } function saveDrafts(drafts) { try { localStorage.setItem(DRAFTS_KEY, JSON.stringify(drafts)); return true; } catch (e) { if (e && e.name === "QuotaExceededError") { alert("Local storage is full — recent image uploads may be too large to save. Try removing a cover image."); } return false; } } // Bring older/partial drafts up to the bilingual + cover shape. function migrateDraft(d) { const out = { ...d }; if (!out.i18n) { out.i18n = { en: { title: d.title || "", excerpt: d.excerpt || "", body: d.body || "" }, es: { title: "", excerpt: "", body: "" }, }; } else { out.i18n = { en: { title: "", excerpt: "", body: "", ...(out.i18n.en || {}) }, es: { title: "", excerpt: "", body: "", ...(out.i18n.es || {}) }, }; } if (!("cover" in out)) out.cover = null; delete out.title; delete out.excerpt; delete out.body; return out; } // First visit seeds the workshop with the canonical posts to edit. function getInitialDrafts() { const existing = loadDrafts(); if (existing && Array.isArray(existing)) return existing.map(migrateDraft); const seed = CANON_POSTS.map((p) => JSON.parse(JSON.stringify(p))); saveDrafts(seed); return seed; } function newDraft() { const iso = new Date().toISOString().slice(0, 10); return { id: "draft-" + Date.now().toString(36), dateISO: iso, date: isoToDisplay(iso), tag: "Patchwork", author: "Mabu", status: "draft", thumb: "DEVLOG IMG · UNTITLED", cover: null, i18n: { en: { title: "", excerpt: "", body: "" }, es: { title: "", excerpt: "", body: "" }, }, }; } function isoToDisplay(iso) { if (!iso || iso.length < 10) return ""; const [y, m, d] = iso.split("-"); return `${d} · ${m} · ${y.slice(2)}`; } // Load an image File, downscale it (max width), return a JPEG data URL so it // stays small enough to persist in localStorage. function fileToCover(file, maxW = 900) { return new Promise((resolve, reject) => { if (!file || !/^image\//.test(file.type)) { reject(new Error("not an image")); return; } const reader = new FileReader(); reader.onload = () => { const img = new Image(); img.onload = () => { const scale = Math.min(1, maxW / img.width); const w = Math.max(1, Math.round(img.width * scale)); const h = Math.max(1, Math.round(img.height * scale)); const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h; const ctx = canvas.getContext("2d"); ctx.fillStyle = "#190d2c"; ctx.fillRect(0, 0, w, h); ctx.drawImage(img, 0, 0, w, h); try { resolve(canvas.toDataURL("image/jpeg", 0.82)); } catch (e) { reject(e); } }; img.onerror = reject; img.src = reader.result; }; reader.onerror = reject; reader.readAsDataURL(file); }); } // Returns all published posts from localStorage, sorted newest-first. // Initializes localStorage with the canonical seed on first visit so the // archive always has content even before the workshop has been opened. function getPublishedPosts() { const all = getInitialDrafts(); return all .filter((d) => d.status === "published") .sort((a, b) => b.dateISO.localeCompare(a.dateISO)); } Object.assign(window, { DEVLOG_TAGS, DEVLOG_AUTHORS, DEVLOG_STATUSES, CANON_POSTS, TAG_LABELS, tagLabel, localized, DRAFTS_KEY, loadDrafts, saveDrafts, migrateDraft, getInitialDrafts, newDraft, isoToDisplay, fileToCover, getPublishedPosts, });