qFelix's picture
Brad Did Something - Gradio/FastAPI Space on HF
78c0f6e
Raw
History Blame Contribute Delete
24.4 kB
// boot, fixed-timestep loop, phase machine, interactions (ARCHITECTURE.md D5)
import * as api from "./api.js";
import { G, cacheEls, setState } from "./state.js";
import { keys, on, bindStageClick, typingMode } from "./input.js";
import { drawFloor, SPOTS, NPC_SPOTS } from "./map.js";
import { createPlayer } from "./player.js";
import { createNpcs } from "./npcs.js";
import { updateHud, banner, clearBanner, revenueFloat } from "./hud.js";
import { renderTrail, resetTrail, escapeHtml } from "./papertrail.js";
import * as dlg from "./dialogue.js";
import * as comic from "./comic.js";
import * as fx from "./effects.js";
import { enterBoardroom, exitBoardroom } from "./boardroom.js";
import { floatText, steam, confettiBurst, heartFloat } from "./particles.js";
import { charColor, chibiInBox } from "./sprites.js";
import { sfx } from "./audio.js";
import { setupTouch } from "./touch.js";
const ROAM_SECONDS = 20;
const FIRST_CRISIS_SECONDS = 5;
const INTERACT_DIST = 58; // must beat worst-case approach: blocked below a desk
let player, world, ctx;
let floorOpts = {};
let inBoardroom = false;
let worldObjects = { newspaper: null, envelope: null };
// ----------------------------------------------------------------- helpers
function fitStage() {
const wrap = G.els["stage-wrap"];
const scale = Math.min(wrap.clientWidth / 648, wrap.clientHeight / 488, 1.6);
G.els.stage.style.transform = `scale(${Math.max(scale, 0.5)})`;
}
function arrivalSpot(event) {
switch (event.arrival) {
case "newspaper": return SPOTS.newspaperLanding;
case "envelope": return SPOTS.playerDesk;
case "phone": return SPOTS.phone;
case "hr": return SPOTS.inbox;
case "boardroom": return SPOTS.boardroomDoor;
default: return NPC_SPOTS[event.affected_npc] || SPOTS.inbox;
}
}
function promptFor(event) {
switch (event.arrival) {
case "newspaper": return "SPACE — PICK UP";
case "envelope": return "SPACE — OPEN ENVELOPE";
case "phone": return "SPACE — ANSWER";
case "hr": return "SPACE — CHECK INBOX";
case "boardroom": return "ENTER — BOARDROOM";
default: return "SPACE — TALK";
}
}
let promptEl = null;
function showPrompt(x, y, text, gold = false) {
if (!promptEl) {
promptEl = document.createElement("div");
promptEl.className = "prompt-box";
G.els.fx.appendChild(promptEl);
}
promptEl.className = "prompt-box" + (gold ? " gold" : "");
promptEl.style.left = `${x}px`;
promptEl.style.top = `${y - 64}px`;
// on touch there's no keyboard: the on-screen ACT/GIFT buttons do these
promptEl.textContent = document.body.classList.contains("touch")
? text.replace(/SPACE|ENTER/g, "ACT").replace(/\bG\b/g, "GIFT")
: text;
promptEl.style.display = "block";
}
function hidePrompt() { if (promptEl) promptEl.style.display = "none"; }
// --------------------------------------------------------- event arrivals
function stageArrival(event) {
G.pendingEvent = event;
// kick off the setup comic now so FLUX renders during the walk to the NPC;
// it's purely decorative — if it isn't ready in time we just skip it.
G.setupComicImg = null;
if (event.image_prompt) {
api.comic(G.sessionId, event.image_prompt)
.then((r) => { G.setupComicImg = r.image_b64 || null; })
.catch(() => {});
}
switch (event.arrival) {
case "npc":
sfx.bubble();
world.showBubble(event.affected_npc, "danger");
banner(`${event.affected_npc.toUpperCase()} HAS A SITUATION`);
break;
case "npc_amber":
sfx.amberBubble();
world.showBubble(event.affected_npc, "amber");
banner("SOMETHING PERSONAL IS HAPPENING", "gold");
break;
case "npc_heart":
sfx.heart();
world.showBubble(event.affected_npc, "heart");
banner(`${event.affected_npc.toUpperCase()} WANTS A WORD...`, "gold");
break;
case "newspaper": {
sfx.newspaper();
banner("! THE PRESS HAS THE STORY", "red");
const np = document.createElement("div");
np.className = "newspaper falling";
np.style.left = `${SPOTS.newspaperLanding.x - 17}px`;
np.style.top = `${SPOTS.newspaperLanding.y - 12}px`;
np.textContent = "VELOURA EXEC DOES SOMETHING — sources say Brad";
G.els.world.appendChild(np);
worldObjects.newspaper = np;
world.npcs.brad.setPose("slump");
break;
}
case "envelope": {
sfx.envelope();
const env = document.createElement("div");
env.className = "envelope";
env.style.left = `${SPOTS.playerDesk.x + 8}px`;
env.style.top = `${SPOTS.playerDesk.y + 10}px`;
G.els.world.appendChild(env);
worldObjects.envelope = env;
banner("AN ENVELOPE HAS ARRIVED", "gold");
break;
}
case "phone":
floorOpts.phoneRing = true;
sfx.phoneRingStart();
banner("! CLIENT CALL INCOMING", "red");
break;
case "hr":
fx.hrStamp();
floorOpts.inboxLit = true;
banner("! HR WOULD LIKE A WORD", "red", true);
break;
case "boardroom":
floorOpts.doorGlow = true;
banner("THE BOARD AWAITS — ENTER THE BOARDROOM", "gold", true);
sfx.gold();
break;
}
}
function clearArrivalProps(event) {
world.clearBubbles();
floorOpts.phoneRing = false;
sfx.phoneRingStop();
floorOpts.inboxLit = false;
if (event && event.arrival === "newspaper" && worldObjects.newspaper) {
worldObjects.newspaper.className = "newspaper floor"; // stays. a reminder.
worldObjects.newspaper = null;
}
if (worldObjects.envelope) { worldObjects.envelope.remove();
worldObjects.envelope = null; }
}
// -------------------------------------------------------------- crisis flow
async function fireNextEvent() {
if (G.busy || G.pendingEvent || dlg.isOpen()) return;
G.busy = true;
try {
const { event, state } = await api.nextEvent(G.sessionId);
setState(state);
updateHud(state);
if (event.kind === "review") { await showReview(); return; }
stageArrival(event);
} catch (err) {
console.error(err);
G.roamTimer = 6; // retry shortly
} finally {
G.busy = false;
fx.edgePulse(false);
G.telegraphed = false;
}
}
function openPendingEvent() {
const event = G.pendingEvent;
if (!event) return;
sfx.phoneRingStop(); // answered
if (event.kind === "presentation") { startPresentation(event); return; }
fx.dim(true);
player.frozen = true;
// setup comic (if FLUX finished during the walk) plays first, then dialogue
const setupImg = G.setupComicImg;
G.setupComicImg = null;
comic.showComic(setupImg, event.comic_caption, () => dlg.openCrisis(event, async (responseType, text) => {
if (G.busy) return;
G.busy = true;
const optLabel = event.special === "romance"
? { option_a: "LEAN IN", option_b: "KEEP IT PROFESSIONAL" }
: event.special === "bribery"
? { option_a: "ACCEPT", option_b: "DECLINE" }
: { option_a: "OPTION A", option_b: "OPTION B" };
const youSaid = text || optLabel[responseType] || responseType;
dlg.showThinking(event.affected_npc !== "player"
? event.affected_npc : "the office");
const t0 = Date.now();
try {
const { outcome, state } = await api.respond(G.sessionId, responseType, text);
// fallbacks land fast — keep the thinking rhythm consistent (UI_UX §22)
const wait = Math.max(0, 1200 - (Date.now() - t0));
setTimeout(() => {
setState(state);
clearArrivalProps(event);
G.pendingEvent = null;
updateHud(state);
renderTrail(state.paper_trail);
if (outcome.revenue_delta !== 0) {
revenueFloat(outcome.revenue_delta);
(outcome.revenue_delta > 0 ? sfx.moneyUp : sfx.moneyDown)();
}
fx.playOutcome(outcome.animation, outcome.npc_id, world);
world.applyMoods(state.npc_moods);
floorOpts.gloomy = state.ambient === "gloomy";
const showAfter = () => dlg.showAftermath(outcome, youSaid, () => {
dlg.close();
fx.dim(false);
player.frozen = false;
startRoam();
});
// payoff comic: only exists after the outcome lands, so it renders now
// (thinking box stays up) and swaps to the aftermath when dismissed
if (outcome.image_prompt) {
api.comic(G.sessionId, outcome.image_prompt)
.then((r) => comic.showComic(r.image_b64, outcome.comic_caption, showAfter))
.catch(showAfter);
} else {
showAfter();
}
G.busy = false;
}, wait);
} catch (err) {
console.error(err);
G.busy = false;
dlg.close(); fx.dim(false); player.frozen = false; startRoam();
}
}));
}
function startRoam() {
clearBanner();
if (G.state && G.state.phase === "review") { showReview(); return; }
G.roamTimer = ROAM_SECONDS;
G.telegraphed = false;
G.idleRolled = false;
G.idleAt = ROAM_SECONDS - 6;
chattedNpcs.clear();
}
// -------------------------------------------------------- presentation flow
async function startPresentation(event) {
G.pendingEvent = null; // consume — re-pressing SPACE must not re-enter
floorOpts.doorGlow = false;
clearBanner();
player.frozen = true;
G.busy = true;
try {
const { round_data, state } = await api.presentationRound(G.sessionId, "", "");
setState(state);
fx.wipe(() => {
inBoardroom = true;
G.els.world.style.visibility = "hidden";
enterBoardroom(round_data.presenting_npc, round_data.npc_state);
setTimeout(() => presentRound(round_data), 1200);
});
} catch (err) { console.error(err); player.frozen = false; }
G.busy = false;
}
function presentRound(rd) {
dlg.openBoardRound(rd, async (responseType, text) => {
if (G.busy) return;
G.busy = true;
dlg.showThinking("the board");
try {
const { round_data, state } = await api.presentationRound(
G.sessionId, responseType, text);
setState(state);
updateHud(state);
if (round_data.kind === "round") {
presentRound(round_data);
} else {
// outcome
renderTrail(state.paper_trail);
if (round_data.revenue_delta !== 0) revenueFloat(round_data.revenue_delta);
(round_data.revenue_delta >= 0 ? sfx.moneyUp : sfx.moneyDown)();
dlg.showPresentationOutcome(round_data, async () => {
dlg.close();
G.pendingEvent = null;
if (round_data.final) { await showReview(); return; }
fx.wipe(() => {
exitBoardroom();
inBoardroom = false;
G.els.world.style.visibility = "visible";
player.frozen = false;
world.applyMoods(G.state.npc_moods);
startRoam();
});
});
}
} catch (err) { console.error(err); }
G.busy = false;
});
}
// -------------------------------------------------------------- review flow
const TIER_COLOR = {
hit_target: "var(--bds-neon-lime)", above_600k: "var(--bds-kevin)",
"300k_to_600k": "#ff9c3c", below_300k: "var(--bds-brad)",
};
const TIER_HEAD = {
hit_target: "THE GAME SHIPPED. SOMEHOW.",
above_600k: "THE BOARD IS NOT NOT IMPRESSED",
"300k_to_600k": "THE BOARD WANTS A CALL",
below_300k: "THE BOARD HAS DRAFTED SOMETHING",
};
async function showReview() {
dlg.close(); fx.dim(false);
let data;
try { ({ review: data } = await api.review(G.sessionId)); }
catch (err) { console.error(err); return; }
const el = G.els["review-screen"];
const color = TIER_COLOR[data.tier];
el.innerHTML = `
<div class="rv-card" style="--rv-color:${color}">
<div class="rv-head">Q3 QUARTERLY REVIEW</div>
<div class="rv-revenue" id="rv-count">$0</div>
<div class="rv-gap">${data.gap >= 0 ? "+" : "-"}$${Math.abs(data.gap)
.toLocaleString()} vs $1,000,000 target</div>
<div class="rv-line">${TIER_HEAD[data.tier]}</div>
<div class="rv-section">
<div class="rv-line">FINAL TITLE: <span class="rv-title">
${escapeHtml(data.boss_title).toUpperCase()}</span></div>
<div class="rv-line">CRISES SURVIVED: ${data.crises_survived} ·
PRESS DISASTERS: ${data.press_disasters}</div>
</div>
<div class="rv-section">
<div class="rv-sec-head">THE QUARTER'S GREATEST HITS</div>
${data.highlights.map((h) => `<div class="rv-line">
<span style="color:${h.npc === "board" ? "#ffc73b" : charColor(h.npc)}">
${(h.npc || "?").toUpperCase()}</span> — ${escapeHtml(h.text)}</div>`).join("")}
</div>
<div class="rv-section">
<div class="rv-sec-head">THE BOARD'S VERDICT</div>
<div class="rv-verdict">${escapeHtml(data.verdict)}</div>
</div>
<button class="btn-cta" id="btn-again" style="margin-top:14px;">
PLAY AGAIN &#9654;</button>
</div>`;
sfx.musicStop(); // the office goes quiet for the verdict
fx.fadeBlack(() => {
el.classList.remove("hidden");
sfx.review();
// revenue count-up
const span = el.querySelector("#rv-count");
const total = data.final_revenue;
let cur = 0;
const tick = () => {
cur = Math.min(total, cur + Math.max(5000, total / 60 | 0));
span.textContent = "$" + cur.toLocaleString();
if (cur < total) requestAnimationFrame(tick);
};
tick();
if (data.tier === "hit_target") { confettiBurst(); setTimeout(() => sfx.win(), 500); }
else setTimeout(() => sfx.lose(data.tier), 600);
}, 800);
el.querySelector("#btn-again").addEventListener("click", () => location.reload());
}
// ------------------------------------------------------------ idle moments
const chattedNpcs = new Set();
async function rollIdleMoment() {
if (G.idleRolled || G.busy) return;
G.idleRolled = true;
try {
const { idle, state } = await api.idle(G.sessionId);
setState(state);
if (idle.kind === "banter") {
world.sayBubble(idle.npc_id, idle.line, 5200);
} else if (idle.kind === "eavesdrop") {
world.saySequence(idle.lines);
} else if (idle.kind === "email_waiting") {
floorOpts.inboxLit = true;
sfx.mail();
banner("YOU'VE GOT (INTERNAL) MAIL");
}
} catch (err) {
// cap hit or phase changed mid-flight — fine, stay quiet
if (err.status !== 409) console.error(err);
}
}
async function startChat(npcId) {
if (G.busy || chattedNpcs.has(npcId)) return;
G.busy = true;
player.frozen = true;
fx.dim(true);
try {
const { chat } = await api.chat(G.sessionId, npcId, "");
chattedNpcs.add(npcId);
dlg.openChat(npcId, chat.npc_line, async (text) => {
if (G.busy) return;
G.busy = true;
dlg.showThinking(npcId);
try {
const { chat: reply, state } = await api.chat(G.sessionId, npcId, text);
setState(state);
world.applyMoods(state.npc_moods);
dlg.showChatReply(reply.npc_line, reply.relationship_delta, endChat);
if (reply.relationship_delta) {
const npc = world.npcs[npcId];
floatText(npc.x, npc.y - 36,
(reply.relationship_delta > 0 ? "+" : "") + reply.relationship_delta,
reply.relationship_delta > 0
? "var(--bds-neon-magenta)" : "var(--bds-grey)");
}
} catch (err) { console.error(err); endChat(); }
G.busy = false;
}, endChat);
} catch (err) {
if (err.status === 409) {
const npc = world.npcs[npcId];
floatText(npc.x, npc.y - 36, "...", "var(--bds-grey-dim)");
} else console.error(err);
fx.dim(false);
player.frozen = false;
}
G.busy = false;
}
function endChat() {
dlg.close();
fx.dim(false);
player.frozen = false;
}
async function openInboxEmail() {
if (G.busy) return;
try {
const { email, state } = await api.readEmail(G.sessionId);
setState(state);
floorOpts.inboxLit = false;
clearBanner();
player.frozen = true;
fx.dim(true);
dlg.openEmail(email, endChat);
} catch (err) { if (err.status !== 409) console.error(err); }
}
// ----------------------------------------------------------------- gifting
function openGiftPanel(npcId) {
if (G.state.phase !== "free_roam" || G.pendingEvent) return;
const panel = G.els["gift-panel"];
const tiers = [["small", "FLOWERS", 200], ["medium", "LUNCH", 350],
["large", "GIFT CARD", 500]];
panel.style.setProperty("--npc-color", charColor(npcId));
panel.innerHTML = `
<div class="gift-head">GIFT FOR ${npcId.toUpperCase()}</div>
<div class="gift-balance">POCKET: $${G.state.pocket_money.toLocaleString()}
— this is YOUR money</div>
${tiers.map(([tier, label, cost]) => `
<button class="gift-row" data-tier="${tier}"
${G.state.pocket_money < cost ? "disabled" : ""}>
<span>${label}</span><span>$${cost}</span></button>`).join("")}
<button class="gift-cancel">ESC — NEVER MIND</button>`;
panel.classList.remove("hidden");
panel.querySelectorAll(".gift-row").forEach((b) =>
b.addEventListener("click", async () => {
panel.classList.add("hidden");
try {
const { result, state } = await api.gift(G.sessionId, npcId, b.dataset.tier);
setState(state); updateHud(state);
const npc = world.npcs[npcId];
fx.playOutcome("heart_float", npcId, world);
floatText(npc.x, npc.y - 30,
`+${result.relationship_delta}${result.halved ? " (again so soon?)" : ""}`,
"var(--bds-neon-magenta)");
if (result.unlocked) floatText(npc.x, npc.y - 46, "✓ something changed",
"#ffc73b");
world.applyMoods(state.npc_moods);
} catch (err) { console.error(err); }
}));
panel.querySelector(".gift-cancel").addEventListener("click",
() => panel.classList.add("hidden"));
}
async function buyCoffeeRound() {
if (G.state.pocket_money < 50) {
floatText(SPOTS.coffee.x, SPOTS.coffee.y - 20, "NO FUNDS", "var(--bds-brad)");
return;
}
try {
const { state } = await api.gift(G.sessionId, "brad", "coffee");
setState(state); updateHud(state);
floatText(SPOTS.coffee.x, SPOTS.coffee.y - 24, "TEAM COFFEE -$50",
"var(--bds-neon-lime)");
sfx.coffee();
world.applyMoods(state.npc_moods);
} catch (err) { console.error(err); }
}
// -------------------------------------------------------------- interaction
function nearestInteraction() {
if (G.pendingEvent) {
const spot = arrivalSpot(G.pendingEvent);
if (player.distTo(spot) < INTERACT_DIST + (G.pendingEvent.kind ===
"presentation" ? 16 : 0))
return { kind: "event", spot, prompt: promptFor(G.pendingEvent),
gold: G.pendingEvent.kind === "presentation" };
}
if (G.state && G.state.phase === "free_roam" && !G.pendingEvent) {
if (G.state.email_waiting && player.distTo(SPOTS.inbox) < INTERACT_DIST)
return { kind: "email", spot: SPOTS.inbox, prompt: "SPACE — READ EMAIL" };
for (const [id, spot] of Object.entries(NPC_SPOTS))
if (player.distTo(spot) < INTERACT_DIST) {
const talked = chattedNpcs.has(id);
const parts = [];
if (!talked) parts.push("SPACE — TALK");
if (G.state.gift_available) parts.push("G — GIFT");
return { kind: "npc_idle", npcId: id, spot,
prompt: parts.join(" / ") || "..." };
}
if (player.distTo(SPOTS.coffee) < INTERACT_DIST)
return { kind: "coffee", spot: SPOTS.coffee, prompt: "SPACE — TEAM COFFEE $50" };
}
return null;
}
function handleInteract() {
if (comic.isOpen()) { comic.dismissComic(); return; } // SPACE advances comic
if (dlg.isOpen()) return;
const hit = nearestInteraction();
if (!hit) return;
if (hit.kind === "event") openPendingEvent();
else if (hit.kind === "coffee") buyCoffeeRound();
else if (hit.kind === "email") openInboxEmail();
else if (hit.kind === "npc_idle" && !chattedNpcs.has(hit.npcId))
startChat(hit.npcId);
}
// --------------------------------------------------------------------- loop
let last = 0;
function loop(ts) {
const dt = Math.min(0.05, (ts - last) / 1000 || 0.016);
last = ts;
if (!inBoardroom && G.phase !== "title") {
drawFloor(ctx, floorOpts);
player.update(dt, keys);
// soft footsteps while walking (rate-limited inside sfx)
if (player.pose === "walk" && !player.frozen) sfx.footstep();
// roam countdown → next event
if (G.state && G.state.phase === "free_roam" && !G.pendingEvent &&
!dlg.isOpen() && !G.busy && !G.state.game_over) {
G.roamTimer -= dt;
if (!G.idleRolled && G.idleAt >= 0 && G.roamTimer <= G.idleAt)
rollIdleMoment(); // one AI ambient moment per gap, ~6s in
if (G.roamTimer <= 3 && !G.telegraphed) {
fx.edgePulse(true); G.telegraphed = true;
}
if (G.roamTimer <= 0) fireNextEvent();
}
// proximity prompt
const hit = nearestInteraction();
if (hit) showPrompt(hit.spot.x, hit.spot.y, hit.prompt, hit.gold);
else hidePrompt();
// ambient steam
if (Math.random() < 0.02) steam(SPOTS.coffee.x, SPOTS.coffee.y - 14);
// a heart drifts over whoever the player is dating
if (G.state && Math.random() < 0.012) {
const romancing = Object.entries(G.state.npc_romance || {})
.find(([, s]) => s === "active");
if (romancing) {
const npc = world.npcs[romancing[0]];
if (npc) heartFloat(npc.x, npc.y - 44);
}
}
}
requestAnimationFrame(loop);
}
// --------------------------------------------------------------------- boot
async function startGame() {
const { session_id, state } = await api.newGame();
G.sessionId = session_id;
setState(state);
resetTrail();
updateHud(state, false);
G.els["title-screen"].classList.add("hidden");
G.els.hud.classList.remove("hidden");
requestAnimationFrame(fitStage); // re-fit now that the HUD shrinks the frame
banner("> a crisis approaches...", "");
G.roamTimer = FIRST_CRISIS_SECONDS;
G.idleRolled = false;
G.idleAt = -1; // no ambient moment in the short intro gap
sfx.open();
sfx.musicStart(); // looping background music (static/audio/bgm.mp3)
}
function buildTitleCast() {
const cast = document.getElementById("ts-cast");
if (!cast) return;
const lineup = [
["brad", { armPose: "out", mouth: "open" }],
["stacey", { armPose: "wring", mouth: "smile" }],
["kevin", { armPose: "point", mouth: "smirk" }],
["janet", { armPose: "open", mouth: "open" }],
["derek", { mouth: "flat" }],
];
for (const [id, expr] of lineup) {
const slot = document.createElement("div");
slot.className = "cast-slot";
slot.appendChild(chibiInBox(id, expr, 60, 72));
const name = document.createElement("div");
name.className = "cast-name";
name.textContent = id.toUpperCase();
name.style.color = charColor(id);
slot.appendChild(name);
cast.appendChild(slot);
}
}
function init() {
cacheEls();
window.BDS_DEBUG = G; // read-only debug/test handle
buildTitleCast();
setupTouch(); // on-screen joystick + buttons for phones/tablets
api.warm().catch(() => {}); // wake Modal while the player reads the title
ctx = G.els.floor.getContext("2d");
player = createPlayer(G.els.world);
world = createNpcs(G.els.world);
fitStage();
window.addEventListener("resize", fitStage);
document.getElementById("btn-start").addEventListener("click", startGame);
on("interact", handleInteract);
on("gift", () => {
const hit = nearestInteraction();
if (hit && hit.kind === "npc_idle" && G.state.gift_available)
openGiftPanel(hit.npcId);
});
on("escape", () => {
G.els["gift-panel"].classList.add("hidden");
// ESC closes a crisis dialogue only before a response is sent —
// the crisis stays pending (bubble remains, walk back to reopen)
if (dlg.isOpen() && dlg.stage() === "question" && !G.busy &&
G.pendingEvent && G.pendingEvent.kind !== "presentation") {
dlg.close();
fx.dim(false);
player.frozen = false;
}
});
on("option", (which) => dlg.chooseOption(which));
on("mute", () => sfx.toggleMute());
bindStageClick(G.els.stage, (x, y) => {
if (dlg.isOpen() || player.frozen || typingMode) return;
player.target = { x, y };
// clicking near an interactable walks there; arrival handled by prompts
});
requestAnimationFrame(loop);
}
init();