qFelix's picture
Brad Did Something - Gradio/FastAPI Space on HF
78c0f6e
Raw
History Blame Contribute Delete
34.7 kB
/* ============================================================
chibi.js β€” shared sprite engine for Brad Did Something
Ported from "Character Sprite Sheet.html" v1.0 and extended
with v2 emotional/talk poses, top-down floor sprites and a
box-shadow pixel-glyph drawer (hearts, stars, drops, Z).
Vanilla JS. Load with <script src="../assets/chibi.js">.
Exposes window.BDSChibi.
============================================================ */
(function () {
const DARK = "var(--bds-void)";
/* ---------- shared styles (injected once) ---------- */
const css = `
.chibi { position: relative; transform-origin: bottom center; z-index: 2; }
.chibi * { position: absolute; box-sizing: border-box; }
.chibi .part { border: 3px solid var(--bds-void); }
.chibi .head { border-radius: 44% 44% 46% 46%; z-index: 4; }
.chibi .hairpc { border: 3px solid var(--bds-void); z-index: 6; }
.chibi .collar { border-radius: 50%; z-index: 5; }
.chibi .torso { border-radius: 42% 42% 26% 26%; z-index: 3; }
.chibi .arm { border-radius: 45%; z-index: 2; transform-origin: top center; }
.chibi .hand { border-radius: 50%; z-index: 3; }
.chibi .leg { border-radius: 22% 22% 30% 30%; z-index: 3; }
.chibi .eye { background: var(--bds-void); border-radius: 50%; z-index: 7; }
.chibi .brow { z-index: 8; border-radius: 2px; }
.chibi .lash { background: var(--bds-void); z-index: 8; }
.chibi .glasses{ border: 3px solid var(--bds-void); background: transparent; border-radius: 36%; z-index: 8; }
.chibi .bridge { background: var(--bds-void); z-index: 8; }
.chibi .mouth { z-index: 7; }
.chibi .blush { z-index: 7; border-radius: 50%; opacity: .8; }
.chibi .sweat { background: var(--bds-stacey); border-radius: 50% 50% 50% 50% / 65% 65% 40% 40%; z-index: 9; box-shadow: 0 0 6px var(--bds-stacey); }
.chibi .pxglyph { z-index: 10; }
.bds-top-sprite { position: absolute; width: 28px; height: 34px; z-index: 5; transform-origin: 50% 90%; }
.bds-top-sprite > i { position: absolute; display: block; }
@media (prefers-reduced-motion: no-preference) {
.a-idle { animation: bds-c-idle 2.6s ease-in-out infinite; }
.a-bob { animation: bds-c-bob var(--spd,.5s) ease-in-out infinite; }
.a-walk .leg.l { animation: bds-c-lega var(--spd,.5s) ease-in-out infinite; }
.a-walk .leg.r { animation: bds-c-legb var(--spd,.5s) ease-in-out infinite; }
.a-walk .arm.l { animation: bds-c-arma var(--spd,.5s) ease-in-out infinite; }
.a-walk .arm.r { animation: bds-c-armb var(--spd,.5s) ease-in-out infinite; }
.a-crisis { animation: bds-c-jolt .45s ease-out infinite alternate; }
.a-rise { animation: bds-c-rise 3.6s ease-in-out infinite; }
.a-talkarm .arm.gesture { animation: bds-c-wave .7s ease-in-out infinite alternate; }
.a-mouth .mouth { animation: bds-c-flap .42s steps(2) infinite; }
.a-mouth-slow .mouth { animation: bds-c-flap 1.4s steps(2) infinite; }
.a-wring .arm.l { animation: bds-c-wra .55s ease-in-out infinite alternate; }
.a-wring .arm.r { animation: bds-c-wrb .55s ease-in-out infinite alternate; }
.a-type .arm.r { animation: bds-c-tap .3s steps(2) infinite; }
.a-nod .head, .a-nod .hairpc { animation: bds-c-nod 3s ease-in-out infinite; }
.a-shiver { animation: bds-c-shiver .22s steps(2) infinite; }
.a-bounce { animation: bds-c-bounce .6s var(--ease-pop, ease-out) infinite alternate; }
.a-peek { animation: bds-c-peek 3.2s steps(1,end) infinite; }
.a-tear .tearpx { animation: bds-c-tear 1.1s steps(3) infinite; }
.a-zzz .zpx { animation: bds-c-zzz 2.4s steps(4) infinite; }
.a-top-idle { animation: bds-top-idle 1.6s steps(2) infinite; }
.a-top-walk { animation: bds-top-walk var(--spd,.5s) ease-in-out infinite; }
/* articulated floor walk: legs step in opposition, arms counter-swing */
.a-top-walk .fl-leg-l { animation: bds-fl-stepA var(--spd,.5s) ease-in-out infinite; }
.a-top-walk .fl-leg-r { animation: bds-fl-stepB var(--spd,.5s) ease-in-out infinite; }
.a-top-walk .fl-arm-l { animation: bds-fl-swingA var(--spd,.5s) ease-in-out infinite; }
.a-top-walk .fl-arm-r { animation: bds-fl-swingB var(--spd,.5s) ease-in-out infinite; }
.a-top-shake { animation: bds-c-shiver .2s steps(2) infinite; }
}
@keyframes bds-c-idle { 0%,100%{ transform: translateY(0) rotate(0); } 50%{ transform: translateY(-1px) rotate(-1.5deg); } }
@keyframes bds-c-bob { 50% { transform: translateY(-3px); } }
@keyframes bds-c-lega { 50% { transform: translateY(-5px) rotate(8deg); } }
@keyframes bds-c-legb { 50% { transform: translateY(0) rotate(-8deg); } }
@keyframes bds-c-arma { 50% { transform: rotate(18deg); } }
@keyframes bds-c-armb { 50% { transform: rotate(-18deg); } }
@keyframes bds-c-jolt { from { transform: translateY(2px) rotate(1.5deg);} to { transform: translateY(-4px) rotate(-1.5deg);} }
@keyframes bds-c-rise { 0%,28%{ transform: translateY(12px);} 72%,100%{ transform: translateY(0);} }
@keyframes bds-c-wave { from{ transform: rotate(-12deg);} to{ transform: rotate(-58deg);} }
@keyframes bds-c-flap { 50% { transform: scaleY(.4); } }
@keyframes bds-c-wra { from{ transform: rotate(14deg);} to{ transform: rotate(30deg) translateY(-2px);} }
@keyframes bds-c-wrb { from{ transform: rotate(-14deg);} to{ transform: rotate(-30deg) translateY(-2px);} }
@keyframes bds-c-tap { 50% { transform: translateY(-3px); } }
@keyframes bds-c-nod { 0%,40%,100%{ transform: translateY(0);} 50%,58%{ transform: translateY(2px);} }
@keyframes bds-c-shiver { 50% { transform: translateX(2px); } }
@keyframes bds-c-bounce { to { transform: translateY(-5px); } }
@keyframes bds-c-peek { 0%,60%,100% { transform: translateX(0); } 70%,90% { transform: translateX(7px); } }
@keyframes bds-c-tear { 0%{ transform: translateY(0); opacity:1;} 100%{ transform: translateY(14px); opacity:0;} }
@keyframes bds-c-zzz { 0%{ transform: translate(0,0); opacity:0;} 25%{ opacity:1;} 100%{ transform: translate(8px,-16px); opacity:0;} }
@keyframes bds-top-idle { 50% { transform: translateY(-2px); } }
@keyframes bds-top-walk { 0%,100%{transform:translateY(0);} 25%,75%{transform:translateY(-2px);} 50%{transform:translateY(0);} }
@keyframes bds-fl-stepA { 0%,100%{transform:translateY(0);} 50%{transform:translateY(-3.5px);} }
@keyframes bds-fl-stepB { 0%,100%{transform:translateY(-3.5px);} 50%{transform:translateY(0);} }
@keyframes bds-fl-swingA { 0%,100%{transform:translateY(-1.5px);} 50%{transform:translateY(1px);} }
@keyframes bds-fl-swingB { 0%,100%{transform:translateY(1px);} 50%{transform:translateY(-1.5px);} }
`;
const styleEl = document.createElement("style");
styleEl.textContent = css;
document.head.appendChild(styleEl);
/* ---------- character registry (lowercase keys) ---------- */
const CHARS = {
player: { key:"PLAYER", color:"--bds-white", u:132, wf:1.00, bw:14, bh:22, hd:10, walk:".5s",
face:{ hair:"#5d5b86", style:"short", eyes:"dot", brows:null, mouth:"neutral", glasses:false, woman:false } },
brad: { key:"BRAD", color:"--bds-brad", u:134, wf:1.16, bw:18, bh:22, hd:11, walk:".34s",
face:{ hair:"#7a4626", style:"messy", eyes:"big", brows:"worried", mouth:"worried", glasses:false, woman:false, sweat:true } },
stacey: { key:"STACEY", color:"--bds-stacey", u:118, wf:0.90, bw:12, bh:20, hd:9, walk:".42s",
face:{ hair:"#9a5a32", style:"long", eyes:"round", brows:null, mouth:"smile", glasses:false, woman:true } },
kevin: { key:"KEVIN", color:"--bds-kevin", u:132, wf:1.00, bw:14, bh:22, hd:12, walk:".5s", roundHead:true,
face:{ hair:"#33324a", style:"neat", eyes:"round", brows:"stern", mouth:"smirk", glasses:true, woman:false } },
janet: { key:"JANET", color:"--bds-janet", u:138, wf:1.00, bw:14, bh:23, hd:10, walk:".46s",
face:{ hair:"#241a30", style:"long", eyes:"round", brows:null, mouth:"smile", glasses:false, woman:true, streak:"var(--bds-neon-magenta)" } },
derek: { key:"DEREK", color:"--bds-derek", u:148, wf:1.18, bw:18, bh:24, hd:11, walk:"1.1s",
face:{ hair:"#6f6a52", style:"bald", eyes:"sleepy", brows:"flat", mouth:"flat", glasses:false, woman:false } },
/* generic grey-suit board member; pass face overrides via opts */
board: { key:"BOARD", color:"--bds-grey", u:138, wf:1.06, bw:16, bh:23, hd:10, walk:".6s", suit:"#3c3a52",
face:{ hair:"#4a485e", style:"neat", eyes:"dot", brows:"flat", mouth:"flat", glasses:false, woman:false } },
/* Kevin's parent β€” taller, older */
parent: { key:"PARENT", color:"--bds-kevin", u:158, wf:1.04, bw:16, bh:26, hd:11, walk:".7s", suit:"#6b632e",
face:{ hair:"#c9c4d8", style:"bald", eyes:"dot", brows:"flat", mouth:"smile", glasses:true, woman:false } },
};
/* ---------- pixel glyphs (box-shadow pixel art) ---------- */
const GLYPHS = {
heart: ["Β·##Β·##Β·", "#######", "#######", "Β·#####Β·", "Β·Β·###Β·Β·", "Β·Β·Β·#Β·Β·Β·"],
star: ["Β·Β·Β·#Β·Β·Β·", "Β·Β·###Β·Β·", "#######", "Β·#####Β·", "Β·Β·#Β·#Β·Β·", "Β·#Β·Β·Β·#Β·"],
drop: ["Β·Β·#Β·Β·", "Β·###Β·", "#####", "#####", "Β·###Β·"],
z: ["####", "Β·Β·#Β·", "Β·#Β·Β·", "####"],
money: ["Β·###Β·", "#Β·#Β·Β·", "Β·###Β·", "Β·Β·#Β·#", "Β·###Β·"],
excl: ["##", "##", "##", "Β·Β·", "##"],
flame: ["Β·Β·#Β·Β·", "Β·##Β·Β·", "Β·###Β·", "#####", "Β·###Β·"],
};
function pixelGlyph(rows, px, color, glow) {
if (typeof rows === "string") rows = GLYPHS[rows];
px = px || 3;
const shadows = [];
rows.forEach((row, y) => {
[...row].forEach((ch, x) => {
if (ch === "#") shadows.push(`${x * px}px ${y * px}px 0 0 ${color}`);
});
});
const d = document.createElement("i");
d.className = "pxglyph";
d.style.cssText = `display:block;position:relative;width:${px}px;height:${px}px;` +
`margin-right:${(rows[0].length - 1) * px}px;margin-bottom:${(rows.length - 1) * px}px;` +
`background:transparent;box-shadow:${shadows.join(",")};` +
(glow ? `filter:drop-shadow(0 0 4px ${color});` : "");
return d;
}
function glyphHTML(name, px, color, extra) {
const rows = GLYPHS[name];
const shadows = [];
rows.forEach((row, y) => [...row].forEach((ch, x) => {
if (ch === "#") shadows.push(`${x * px}px ${y * px}px 0 0 ${color}`);
}));
return `<i class="pxglyph" style="display:block;width:${px}px;height:${px}px;box-shadow:${shadows.join(",")};${extra || ""}"></i>`;
}
/* ---------- front-view chibi ----------
state: idle|down|up|left|right|crisis|talk
opts: { eyes ('closed'|'happy'|'heart'|'wide'), mouth ('open'|'frown'|'wavy'|
'smile'|'flat'|'worried'|'neutral'|'smirk'), brows, armPose
('out'|'palms'|'point'|'open'|'hips'|'behindhead'|'wring'),
tears, sweat, zzz, heartAbove, blush, tilt (deg), anim (extra classes),
suit (torso color override), noBubble, scale } */
function chibi(who, state, opts) {
opts = opts || {};
const c = typeof who === "string" ? CHARS[who] : who;
const u = c.u, wf = c.wf;
const col = opts.suit || c.suit ? `${opts.suit || c.suit}` : `var(${c.color})`;
const skin = "var(--bds-white)";
const pants = "#241a44";
const F = Object.assign({}, c.face, opts.face || {});
const headD = Math.round((c.roundHead ? 0.50 : 0.46) * u);
const torsoW = Math.round(0.40 * u * wf);
const torsoH = Math.round(0.30 * u);
const armW = Math.round(0.13 * u), armH = Math.round(0.26 * u);
const legW = Math.round(0.16 * u), legH = Math.round(0.17 * u), legGap = Math.round(0.05 * u);
const handD = Math.round(0.13 * u);
const torsoBottomY = Math.round(legH * 0.45);
const torsoTopY = torsoBottomY + torsoH;
const headBottomY = torsoTopY - Math.round(headD * 0.22);
const headTopY = headBottomY + headD;
const H = headTopY + Math.round(0.06 * u);
const W = Math.round(Math.max(headD, torsoW + armW * 1.4) + Math.round(0.18 * u));
const cx = W / 2;
const headLeft = cx - headD / 2;
const isBack = state === "up";
const side = (state === "left" || state === "right") ? state : null;
const px = (fx) => headLeft + headD * fx;
const py = (fy) => headBottomY + headD * (1 - fy);
const el = document.createElement("div");
el.className = "chibi";
el.style.width = W + "px"; el.style.height = H + "px";
if (opts.scale) { el.style.transform = `scale(${opts.scale})`; }
if (opts.tilt) { el.style.transform = (el.style.transform || "") + ` rotate(${opts.tilt}deg)`; }
let html = "";
/* long hair back panels */
if (F.style === "long") {
const hw = Math.round(headD * 0.26), hh = (headTopY - torsoBottomY) - Math.round(0.02 * u);
const hbot = torsoBottomY + Math.round(0.02 * u);
const z = isBack ? 7 : 3;
html += `<div class="hairpc" style="width:${hw}px;height:${hh}px;left:${headLeft - Math.round(0.01*u)}px;bottom:${hbot}px;background:${F.hair};border-radius:60% 40% 45% 55%;z-index:${z};"></div>`;
html += `<div class="hairpc" style="width:${hw}px;height:${hh}px;left:${headLeft + headD - hw + Math.round(0.01*u)}px;bottom:${hbot}px;background:${F.hair};border-radius:40% 60% 55% 45%;z-index:${z};"></div>`;
if (F.streak) html += `<div class="hairpc" style="width:${Math.round(hw*0.4)}px;height:${Math.round(hh*0.8)}px;left:${headLeft + headD - hw + Math.round(0.01*u)}px;bottom:${hbot}px;background:${F.streak};border:none;border-radius:40% 60% 55% 45%;z-index:${z+1};"></div>`;
}
/* legs */
html += `<div class="part leg l" style="width:${legW}px;height:${legH}px;left:${cx - legGap/2 - legW}px;bottom:0;background:${pants};"></div>`;
html += `<div class="part leg r" style="width:${legW}px;height:${legH}px;left:${cx + legGap/2}px;bottom:0;background:${pants};"></div>`;
/* arms */
const armBottom = torsoBottomY + Math.round(0.03 * u);
const pose = opts.armPose;
function armPair(rotL, rotR, raiseL, raiseR) {
const lx = cx - torsoW/2 - armW + Math.round(0.03*u);
const rx = cx + torsoW/2 - Math.round(0.03*u);
/* hand nests INSIDE the arm so it inherits the arm's rotation β€”
a sibling hand with a linear offset drifts off-body at high rotations */
const hand = `<div class="part hand" style="width:${handD}px;height:${handD}px;left:50%;margin-left:${-Math.round(handD/2) - 3}px;bottom:${-Math.round(handD * 0.4)}px;background:${skin};"></div>`;
html += `<div class="part arm l" style="width:${armW}px;height:${armH}px;left:${lx}px;bottom:${armBottom + (raiseL||0)}px;background:${col};transform:rotate(${rotL}deg);">${hand}</div>`;
html += `<div class="part arm r gesture" style="width:${armW}px;height:${armH}px;left:${rx}px;bottom:${armBottom + (raiseR||0)}px;background:${col};transform:rotate(${rotR}deg);">${hand}</div>`;
}
if (side) {
const fx = side === "right" ? 1 : -1;
html += `<div class="part arm gesture" style="width:${armW}px;height:${armH}px;left:${cx - armW/2 + fx*Math.round(0.10*u)}px;bottom:${armBottom}px;background:${col};transform:rotate(${fx*22}deg);"></div>`;
} else if (pose === "out") armPair(8, -70, 0, Math.round(0.06*u));
else if (pose === "open") armPair(46, -46, Math.round(0.04*u), Math.round(0.04*u));
else if (pose === "palms") armPair(58, -58, Math.round(0.07*u), Math.round(0.07*u));
else if (pose === "point") armPair(6, -88, 0, Math.round(0.10*u));
else if (pose === "hips") armPair(-34, 34, 0, 0);
else if (pose === "behindhead") armPair(4, -160, 0, Math.round(0.16*u));
else if (pose === "wring") armPair(22, -22, 0, 0);
else if (pose === "crossed") {
html += `<div class="part arm" style="width:${torsoW*0.92}px;height:${Math.round(armH*0.5)}px;left:${cx - torsoW*0.46}px;bottom:${armBottom + Math.round(0.05*u)}px;background:${col};border-radius:30%;z-index:4;"></div>`;
}
else armPair(0, 0, 0, 0);
/* torso */
html += `<div class="part torso" style="width:${torsoW}px;height:${torsoH}px;left:${cx - torsoW/2}px;bottom:${torsoBottomY}px;background:${col};"></div>`;
if (c.suit || opts.suit) { /* tie pixel for suits */
html += `<div style="width:${Math.round(0.04*u)}px;height:${Math.round(torsoH*0.6)}px;left:${cx - Math.round(0.02*u)}px;bottom:${torsoTopY - Math.round(torsoH*0.62)}px;background:${DARK};z-index:5;"></div>`;
}
if (!isBack && !side) {
const nw = Math.round(headD*0.34), nh = Math.round(0.05*u);
html += `<div class="collar" style="width:${nw}px;height:${nh}px;left:${cx - nw/2}px;bottom:${torsoTopY - nh - 1}px;background:rgba(0,0,0,.28);border-radius:0 0 60% 60%;"></div>`;
}
/* head */
html += `<div class="part head" style="width:${headD}px;height:${headD}px;left:${headLeft}px;bottom:${headBottomY}px;background:${skin};${isBack ? "filter:brightness(.92);" : ""}"></div>`;
/* hair cap */
const cap = (w, h, by, radius) =>
`<div class="hairpc" style="width:${w}px;height:${h}px;left:${cx - w/2}px;bottom:${by}px;background:${F.hair};border-radius:${radius};"></div>`;
if (F.style === "bald") html += cap(Math.round(headD*0.66), Math.round(headD*0.20), headTopY - Math.round(headD*0.22), "50% 50% 14% 14%");
else if (F.style === "messy")html += cap(Math.round(headD*0.94), Math.round(headD*0.46), headTopY - Math.round(headD*0.42), "60% 50% 35% 30% / 70% 65% 30% 30%");
else if (F.style === "neat") html += cap(Math.round(headD*0.92), Math.round(headD*0.40), headTopY - Math.round(headD*0.36), "55% 55% 20% 40%");
else if (F.style === "long") html += cap(Math.round(headD*0.98), Math.round(headD*0.46), headTopY - Math.round(headD*0.40), "55% 55% 40% 40%");
else html += cap(Math.round(headD*0.9), Math.round(headD*0.40), headTopY - Math.round(headD*0.34), "55% 55% 30% 30%");
/* face */
if (!isBack && !opts.hideFace) {
const eyeBase = Math.max(4, Math.round(0.085 * u));
let exL = 0.34, exR = 0.66;
if (side === "right") { exL = 0.50; exR = 0.74; }
if (side === "left") { exL = 0.26; exR = 0.50; }
const eyeY = 0.50;
const eyeStyle = opts.eyes || F.eyes;
const drawEye = (fx) => {
if (eyeStyle === "closed" || eyeStyle === "sleepy") {
const w = Math.round(eyeBase*1.4), h = Math.max(2, Math.round(eyeBase*0.42));
return `<div class="eye" style="width:${w}px;height:${h}px;border-radius:40%;left:${px(fx)-w/2}px;bottom:${py(eyeY)-h/2}px;"></div>`;
}
if (eyeStyle === "happy") {
const w = Math.round(eyeBase*1.5), h = Math.round(eyeBase*0.9);
return `<div class="eye" style="width:${w}px;height:${h}px;background:transparent;border:3px solid ${DARK};border-bottom:none;border-radius:80% 80% 0 0;left:${px(fx)-w/2}px;bottom:${py(eyeY)-h/3}px;"></div>`;
}
if (eyeStyle === "heart") {
return `<div class="eye" style="background:transparent;left:${px(fx)-9}px;bottom:${py(eyeY)-6}px;">${glyphHTML("heart", 3, "var(--bds-neon-magenta)", "filter:drop-shadow(0 0 4px var(--bds-neon-magenta));")}</div>`;
}
if (eyeStyle === "wide") {
const d = Math.round(eyeBase*1.5);
return `<div class="eye" style="width:${d}px;height:${d}px;background:${skin};border:3px solid ${DARK};left:${px(fx)-d/2}px;bottom:${py(eyeY)-d/2}px;"></div>`;
}
const d = eyeStyle === "big" ? Math.round(eyeBase*1.25) : eyeBase;
return `<div class="eye" style="width:${d}px;height:${d}px;left:${px(fx)-d/2}px;bottom:${py(eyeY)-d/2}px;"></div>`;
};
html += drawEye(exL) + drawEye(exR);
if (F.woman && eyeStyle !== "heart") {
const lw = Math.max(3, Math.round(eyeBase*0.7)), lh = Math.max(2, Math.round(0.022*u));
html += `<div class="lash" style="width:${lw}px;height:${lh}px;left:${px(exL)-eyeBase*0.7}px;bottom:${py(eyeY)+eyeBase*0.45}px;transform:rotate(-24deg);border-radius:2px;"></div>`;
html += `<div class="lash" style="width:${lw}px;height:${lh}px;left:${px(exR)+eyeBase*0.0}px;bottom:${py(eyeY)+eyeBase*0.45}px;transform:rotate(24deg);border-radius:2px;"></div>`;
}
const brows = opts.brows !== undefined ? opts.brows : F.brows;
if (brows) {
const bw = Math.round(headD*0.16), bh = Math.max(2, Math.round(0.028*u));
const browY = 0.34;
let rotL = 0, rotR = 0;
if (brows === "worried") { rotL = 16; rotR = -16; }
else if (brows === "stern") { rotL = -15; rotR = 15; }
html += `<div class="brow" style="width:${bw}px;height:${bh}px;background:${F.hair};left:${px(exL)-bw/2}px;bottom:${py(browY)}px;transform:rotate(${rotL}deg);"></div>`;
html += `<div class="brow" style="width:${bw}px;height:${bh}px;background:${F.hair};left:${px(exR)-bw/2}px;bottom:${py(browY)}px;transform:rotate(${rotR}deg);"></div>`;
}
if (F.glasses) {
const gd = Math.round(eyeBase*2.0);
html += `<div class="glasses" style="width:${gd}px;height:${Math.round(gd*0.86)}px;left:${px(exL)-gd/2}px;bottom:${py(eyeY)-gd*0.43}px;"></div>`;
html += `<div class="glasses" style="width:${gd}px;height:${Math.round(gd*0.86)}px;left:${px(exR)-gd/2}px;bottom:${py(eyeY)-gd*0.43}px;"></div>`;
html += `<div class="bridge" style="width:${px(exR)-px(exL)-gd}px;height:3px;left:${px(exL)+gd/2}px;bottom:${py(eyeY)}px;"></div>`;
}
/* mouth */
const mcx = px(0.5);
const mouth = opts.mouth || (state === "talk" ? "open" : F.mouth);
const my = 0.72;
if (mouth === "open") {
const mw = Math.round(headD*0.22), mh = Math.round(headD*0.16);
html += `<div class="mouth" style="width:${mw}px;height:${mh}px;left:${mcx-mw/2}px;bottom:${py(0.74)-mh/2}px;background:${DARK};border-radius:45%;transform-origin:center;"></div>`;
} else if (mouth === "smile") {
const mw = Math.round(headD*0.24), mh = Math.round(headD*0.12);
html += `<div class="mouth" style="width:${mw}px;height:${mh}px;left:${mcx-mw/2}px;bottom:${py(my)-mh}px;border:3px solid ${DARK};border-top:none;background:transparent;border-radius:0 0 80% 80%;"></div>`;
} else if (mouth === "frown") {
const mw = Math.round(headD*0.24), mh = Math.round(headD*0.12);
html += `<div class="mouth" style="width:${mw}px;height:${mh}px;left:${mcx-mw/2}px;bottom:${py(my)-mh/2}px;border:3px solid ${DARK};border-bottom:none;background:transparent;border-radius:80% 80% 0 0;"></div>`;
} else if (mouth === "worried") {
const mw = Math.round(headD*0.16), mh = Math.round(headD*0.13);
html += `<div class="mouth" style="width:${mw}px;height:${mh}px;left:${mcx-mw/2}px;bottom:${py(my)-mh/2}px;background:${DARK};border-radius:48%;"></div>`;
} else if (mouth === "smirk") {
const mw = Math.round(headD*0.22), mh = Math.max(3, Math.round(0.03*u));
html += `<div class="mouth" style="width:${mw}px;height:${mh}px;left:${mcx-mw/2+Math.round(headD*0.04)}px;bottom:${py(my)}px;background:${DARK};border-radius:3px;transform:rotate(-12deg);"></div>`;
} else if (mouth === "wavy") {
const mw = Math.round(headD*0.07);
for (let i = 0; i < 3; i++) {
html += `<div class="mouth" style="width:${mw}px;height:3px;left:${mcx-mw*1.5+i*mw}px;bottom:${py(my)+(i%2?2:0)}px;background:${DARK};"></div>`;
}
} else if (mouth === "flat") {
const mw = Math.round(headD*0.22), mh = Math.max(2, Math.round(0.025*u));
html += `<div class="mouth" style="width:${mw}px;height:${mh}px;left:${mcx-mw/2}px;bottom:${py(my)}px;background:${DARK};border-radius:2px;"></div>`;
} else {
const mw = Math.round(headD*0.12), mh = Math.max(2, Math.round(0.025*u));
html += `<div class="mouth" style="width:${mw}px;height:${mh}px;left:${mcx-mw/2}px;bottom:${py(my)}px;background:${DARK};border-radius:2px;"></div>`;
}
if (opts.blush) {
const bd = Math.round(headD*0.14);
html += `<div class="blush" style="width:${bd}px;height:${Math.round(bd*0.55)}px;left:${px(0.22)}px;bottom:${py(0.66)}px;background:var(--bds-brad);"></div>`;
html += `<div class="blush" style="width:${bd}px;height:${Math.round(bd*0.55)}px;left:${px(0.70)}px;bottom:${py(0.66)}px;background:var(--bds-brad);"></div>`;
}
if (opts.sweat || (F.sweat && (state === "crisis"))) {
const sw = Math.round(0.05*u), sh = Math.round(0.07*u);
html += `<div class="sweat" style="width:${sw}px;height:${sh}px;left:${px(0.84)}px;bottom:${py(0.30)}px;"></div>`;
}
if (opts.tears) {
html += `<div class="tearpx" style="position:absolute;left:${px(0.30)}px;bottom:${py(0.62)}px;z-index:9;">${glyphHTML("drop", 2, "var(--bds-derek)", "filter:drop-shadow(0 0 3px var(--bds-derek));")}</div>`;
html += `<div class="tearpx" style="position:absolute;left:${px(0.66)}px;bottom:${py(0.64)}px;z-index:9;">${glyphHTML("drop", 2, "var(--bds-derek)", "filter:drop-shadow(0 0 3px var(--bds-derek));")}</div>`;
}
}
/* above-head extras */
if (opts.heartAbove) {
html += `<div style="position:absolute;left:${cx + Math.round(headD*0.18)}px;bottom:${headTopY + 6}px;z-index:10;">${glyphHTML("heart", 3, "var(--bds-neon-magenta)", "filter:drop-shadow(0 0 5px var(--bds-neon-magenta));")}</div>`;
}
if (opts.zzz) {
for (let i = 0; i < 3; i++) {
html += `<div class="zpx" style="position:absolute;left:${cx + Math.round(headD*0.34) + i*10}px;bottom:${headTopY - 6 + i*9}px;z-index:10;animation-delay:${i*0.8}s;">${glyphHTML("z", 2, "var(--bds-grey)", "")}</div>`;
}
}
if (opts.sweatDropAbove) {
html += `<div style="position:absolute;left:${px(0.86)}px;bottom:${headTopY - 4}px;z-index:10;">${glyphHTML("drop", 3, "var(--bds-stacey)", "filter:drop-shadow(0 0 4px var(--bds-stacey));")}</div>`;
}
if (state === "crisis" && !opts.noBubble) {
html += `<div style="position:absolute;left:${cx + Math.round(headD*0.20)}px;bottom:${headTopY - 2}px;z-index:10;width:28px;height:28px;border-radius:8px;background:var(--bds-white);border:3px solid ${DARK};display:flex;align-items:center;justify-content:center;font-family:var(--font-display);font-size:13px;color:var(--bds-danger);box-shadow:0 0 12px rgba(255,90,60,.6);">!</div>`;
}
el.innerHTML = html;
/* animation classes */
if (opts.anim) opts.anim.split(" ").forEach(cl => cl && el.classList.add(cl));
else if (state === "idle") {
if (c.key === "DEREK") { /* still */ }
else el.classList.add("a-idle");
} else if (["down","up","left","right"].includes(state)) {
el.classList.add("a-walk","a-bob");
el.style.setProperty("--spd", c.walk);
} else if (state === "crisis") {
el.classList.add(c.key === "DEREK" ? "a-rise" : "a-crisis");
} else if (state === "talk") {
el.classList.add("a-mouth");
}
return el;
}
/* ---------- compact naturalistic 3/4 person (28x34 box) ----------
Shared body for floor sprites. Front-facing (or back when facing up),
skin + hair + office shirt β€” matches the warmlight Office Floor cast.
opts: { skin, hair, shirt, pants, back, style, glasses, streak, sweat,
eyes ('sleepy'|'big'|...), mouth ('smile'|'smirk'|'worried'|...) } */
function personHTML(o, ink) {
const skin = o.skin || "#f0c8a4", hair = o.hair || "#3a2a18",
shirt = o.shirt || "var(--bds-white)", pants = o.pants || "#2c2b3a",
style = o.style || "short", back = o.back;
const R = (x, y, w, h, bg, ex) => `<i style="left:${x}px;top:${y}px;width:${w}px;height:${h}px;background:${bg};${ex || ""}"></i>`;
const RC = (cls, x, y, w, h, bg, ex) => `<i class="${cls}" style="left:${x}px;top:${y}px;width:${w}px;height:${h}px;background:${bg};${ex || ""}"></i>`;
const O = `box-shadow:0 0 0 2px ${ink};`;
let h = "";
// legs + shoes β€” each leg shares its class with its shoe so they step as one
h += RC("fl-leg-l", 7, 25, 5, 8, pants, O) + RC("fl-leg-r", 14, 25, 5, 8, pants, O);
h += RC("fl-leg-l", 6, 31, 7, 3, "#26242f") + RC("fl-leg-r", 13, 31, 7, 3, "#26242f");
// arms + hands β€” each arm shares its class with its hand so they swing as one
h += RC("fl-arm-l", 0, 14, 4, 10, shirt, O) + RC("fl-arm-r", 22, 14, 4, 10, shirt, O);
h += RC("fl-arm-l", 0, 22, 4, 3, skin) + RC("fl-arm-r", 22, 22, 4, 3, skin);
// torso
h += R(4, 13, 18, 14, shirt, O + "border-radius:5px 5px 2px 2px;");
if (o.tie && !back) h += R(12, 14, 3, 10, o.tie);
h += R(11, 11, 4, 4, skin); // neck
// head
h += R(5, 0, 16, 14, skin, O + "border-radius:46% 46% 44% 44%;z-index:2;");
// hair
if (style === "bald") h += R(7, -1, 12, 5, hair, O + "border-radius:50% 50% 12% 12%;z-index:3;");
else if (style === "long") {
h += R(3, 1, 4, 15, hair, O + "border-radius:50% 20% 30% 60%;z-index:1;") + R(19, 1, 4, 15, hair, O + "border-radius:20% 50% 60% 30%;z-index:1;");
h += R(4, -2, 18, 8, hair, O + "border-radius:55% 55% 30% 30%;z-index:3;");
if (o.streak) h += R(18, 0, 4, 12, o.streak, "z-index:4;border-radius:30% 50% 60% 30%;");
} else if (style === "messy") h += R(3, -3, 20, 9, hair, O + "border-radius:60% 50% 40% 40% / 70% 65% 30% 30%;z-index:3;");
else h += R(4, -2, 18, 8, hair, O + "border-radius:55% 55% 25% 35%;z-index:3;");
if (back) {
// back of head β€” fill hair lower, no face
h += R(5, 2, 16, 10, hair, "z-index:3;border-radius:40% 40% 46% 46%;");
} else {
// eyes β€” sleepy reads as half-closed lines, big for the anxious ones
if (o.eyes === "sleepy") {
h += R(9, 7, 3, 1, ink, "z-index:4;border-radius:1px;") + R(14, 7, 3, 1, ink, "z-index:4;border-radius:1px;");
} else {
const eh = o.eyes === "big" ? 4 : 3;
h += R(9, 5, 2, eh, ink, "z-index:4;border-radius:1px;") + R(15, 5, 2, eh, ink, "z-index:4;border-radius:1px;");
}
if (o.glasses) {
h += R(7, 4, 5, 5, "transparent", "z-index:5;border:2px solid " + ink + ";border-radius:2px;") + R(14, 4, 5, 5, "transparent", "z-index:5;border:2px solid " + ink + ";border-radius:2px;") + R(12, 6, 2, 2, ink, "z-index:5;");
}
// mouth β€” a few legible expressions that still read at 16px
const mth = o.mouth;
if (mth === "smile") h += R(10, 10, 6, 3, "transparent", "z-index:4;border:2px solid rgba(120,40,30,.6);border-top:none;border-radius:0 0 70% 70%;");
else if (mth === "smirk") h += R(11, 10, 5, 2, "rgba(120,40,30,.6)", "z-index:4;border-radius:2px;transform:rotate(-13deg);");
else if (mth === "worried") h += R(11, 9, 3, 3, "rgba(120,40,30,.55)", "z-index:4;border-radius:50%;");
else h += R(11, 10, 4, 2, "rgba(120,40,30,.5)", "z-index:4;border-radius:2px;");
if (o.sweat) h += R(18, 3, 3, 4, "#7fc6e8", "z-index:6;border-radius:60% 60% 50% 50%;box-shadow:0 0 4px #7fc6e8;");
}
return h;
}
/* ---------- top-down floor sprite (warmlight 3/4 person) ----------
Back-compatible API. `color` becomes the shirt; identity is kept via
the shirt + hair. opts: { color, hair, skin, pose, dim, facing, scale,
still, glasses, streak, style, sweat } */
function topSprite(opts) {
opts = opts || {};
// pass who:'brad' to inherit that character's colour + face traits; explicit
// color/hair/style/etc. still override (back-compatible).
const c = opts.who && CHARS[opts.who] ? CHARS[opts.who] : null;
const f = c ? c.face : {};
const el = document.createElement("div");
el.className = "bds-top-sprite";
el.style.width = "26px"; el.style.height = "34px";
const anims = { idle: "a-top-idle", walk: "a-top-walk", shake: "a-top-shake" };
const pose = opts.pose;
if (!opts.still && anims[pose]) el.classList.add(anims[pose]);
else if (!opts.still && (pose == null || pose === "idle")) el.classList.add("a-top-idle");
// per-character walk cadence (brad frantic, derek plodding)
el.style.setProperty("--spd", opts.walk || (c && c.walk) || ".5s");
let tf = "";
if (pose === "lean") tf = "rotate(-8deg)";
else if (pose === "slump") tf = "rotate(6deg) translateY(3px)";
if (opts.scale) tf += ` scale(${opts.scale})`;
if (tf) { el.style.transform = tf; el.style.transformOrigin = "50% 100%"; }
if (opts.dim) el.style.filter = "brightness(.6) saturate(.7)";
el.innerHTML = personHTML({
skin: opts.skin,
hair: opts.hair || f.hair,
shirt: opts.color || (c ? `var(${c.color})` : "var(--bds-white)"),
pants: opts.pants, back: opts.facing === "up",
style: opts.style || f.style,
glasses: opts.glasses != null ? opts.glasses : f.glasses,
streak: opts.streak || f.streak,
eyes: opts.eyes || f.eyes,
mouth: opts.mouth || f.mouth,
sweat: opts.sweat, // sweat is a transient state, not identity β€” stays explicit
tie: opts.tie,
}, "var(--bds-void)");
return el;
}
/* top-down desk with monitor */
function desk(opts) {
opts = opts || {};
const el = document.createElement("div");
el.style.cssText = "position:absolute;width:46px;height:22px;left:-23px;top:14px;background:#5a4326;border:2px solid #3a2b16;z-index:4;box-shadow:inset 0 3px 0 #6e542f, inset 0 -3px 0 #3f2f19;";
const mon = document.createElement("div");
mon.style.cssText = `position:absolute;left:50%;top:-9px;transform:translateX(-50%);width:18px;height:12px;background:${opts.dark ? "#0a0c14" : "#10131f"};border:2px solid #2a2f44;${opts.dark ? "" : "box-shadow:0 0 6px rgba(80,140,255,.35);"}`;
el.appendChild(mon);
return el;
}
/* name tag under a floor sprite */
function tag(text, color, opts) {
opts = opts || {};
const el = document.createElement("div");
el.style.cssText = `position:absolute;left:50%;top:${opts.top != null ? opts.top : 34}px;transform:translateX(-50%);font-family:var(--font-display);font-size:6px;letter-spacing:1px;color:${color};text-shadow:${opts.glow === false ? "1px 1px 0 var(--bds-void)" : `1px 1px 0 var(--bds-void), 0 0 6px ${color}`};white-space:nowrap;z-index:5;`;
el.textContent = text;
return el;
}
/* zero-size anchored station */
function station(x, y) {
const el = document.createElement("div");
el.style.cssText = `position:absolute;left:${x}px;top:${y}px;width:0;height:0;z-index:5;`;
for (let i = 2; i < arguments.length; i++) if (arguments[i]) el.appendChild(arguments[i]);
return el;
}
window.BDSChibi = { CHARS, chibi, topSprite, personHTML, desk, tag, station, pixelGlyph, glyphHTML, GLYPHS };
})();