| |
| 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; |
|
|
| let player, world, ctx; |
| let floorOpts = {}; |
| let inBoardroom = false; |
| let worldObjects = { newspaper: null, envelope: null }; |
|
|
| |
|
|
| 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`; |
| |
| 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"; } |
|
|
| |
|
|
| function stageArrival(event) { |
| G.pendingEvent = event; |
| |
| |
| 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"; |
| worldObjects.newspaper = null; |
| } |
| if (worldObjects.envelope) { worldObjects.envelope.remove(); |
| worldObjects.envelope = null; } |
| } |
|
|
| |
|
|
| 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; |
| } finally { |
| G.busy = false; |
| fx.edgePulse(false); |
| G.telegraphed = false; |
| } |
| } |
|
|
| function openPendingEvent() { |
| const event = G.pendingEvent; |
| if (!event) return; |
| sfx.phoneRingStop(); |
| if (event.kind === "presentation") { startPresentation(event); return; } |
| fx.dim(true); |
| player.frozen = true; |
| |
| 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); |
| |
| 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(); |
| }); |
| |
| |
| 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(); |
| } |
|
|
| |
|
|
| async function startPresentation(event) { |
| G.pendingEvent = null; |
| 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 { |
| |
| 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; |
| }); |
| } |
|
|
| |
|
|
| 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 ▶</button> |
| </div>`; |
| sfx.musicStop(); |
| fx.fadeBlack(() => { |
| el.classList.remove("hidden"); |
| sfx.review(); |
| |
| 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()); |
| } |
|
|
| |
|
|
| 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) { |
| |
| 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); } |
| } |
|
|
| |
|
|
| 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); } |
| } |
|
|
| |
|
|
| 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; } |
| 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); |
| } |
|
|
| |
|
|
| 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); |
|
|
| |
| if (player.pose === "walk" && !player.frozen) sfx.footstep(); |
|
|
| |
| 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(); |
| if (G.roamTimer <= 3 && !G.telegraphed) { |
| fx.edgePulse(true); G.telegraphed = true; |
| } |
| if (G.roamTimer <= 0) fireNextEvent(); |
| } |
|
|
| |
| const hit = nearestInteraction(); |
| if (hit) showPrompt(hit.spot.x, hit.spot.y, hit.prompt, hit.gold); |
| else hidePrompt(); |
|
|
| |
| if (Math.random() < 0.02) steam(SPOTS.coffee.x, SPOTS.coffee.y - 14); |
|
|
| |
| 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); |
| } |
|
|
| |
|
|
| 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); |
| banner("> a crisis approaches...", ""); |
| G.roamTimer = FIRST_CRISIS_SECONDS; |
| G.idleRolled = false; |
| G.idleAt = -1; |
| sfx.open(); |
| sfx.musicStart(); |
| } |
|
|
| 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; |
| buildTitleCast(); |
| setupTouch(); |
| api.warm().catch(() => {}); |
| 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"); |
| |
| |
| 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 }; |
| |
| }); |
|
|
| requestAnimationFrame(loop); |
| } |
|
|
| init(); |
|
|