// 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 = `
Q3 QUARTERLY REVIEW
$0
${data.gap >= 0 ? "+" : "-"}$${Math.abs(data.gap) .toLocaleString()} vs $1,000,000 target
${TIER_HEAD[data.tier]}
FINAL TITLE: ${escapeHtml(data.boss_title).toUpperCase()}
CRISES SURVIVED: ${data.crises_survived} · PRESS DISASTERS: ${data.press_disasters}
THE QUARTER'S GREATEST HITS
${data.highlights.map((h) => `
${(h.npc || "?").toUpperCase()} — ${escapeHtml(h.text)}
`).join("")}
THE BOARD'S VERDICT
${escapeHtml(data.verdict)}
`; 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 = `
GIFT FOR ${npcId.toUpperCase()}
POCKET: $${G.state.pocket_money.toLocaleString()} — this is YOUR money
${tiers.map(([tier, label, cost]) => ` `).join("")} `; 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();