diff mrjunejune/src/jrpg/jrpg.js @ 259:667156fcd3e3

Add dev-only cyberpunk JRPG page
author MrJuneJune <me@mrjunejune.com>
date Wed, 05 Aug 2026 09:19:41 -0700
parents
children 1f9877b637e9
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/jrpg/jrpg.js	Wed Aug 05 09:19:41 2026 -0700
@@ -0,0 +1,231 @@
+const CHARACTER_STATES = Object.freeze({
+  default: {
+    alt: "Epi the Shiba Inu standing happily",
+    src: "/public/jrpg/shiba-default.webp",
+  },
+  sad: {
+    alt: "Epi the Shiba Inu sitting sadly",
+    src: "/public/jrpg/shiba-sad.webp",
+  },
+  thinking: {
+    alt: "Epi the Shiba Inu tilting her head in thought",
+    src: "/public/jrpg/shiba-thinking.webp",
+  },
+});
+
+const PREVIEWS = Object.freeze({
+  resume: {
+    copy: "Experience, projects, and the systems I have helped build.",
+    kicker: "CHARACTER RECORD",
+    title: "Resume",
+    type: "Profile",
+    url: "/resume",
+  },
+  tools: {
+    copy: "Small, focused utilities for writing, media, and experimentation.",
+    kicker: "ITEM INVENTORY",
+    title: "Tools",
+    type: "Utilities",
+    url: "/tools",
+  },
+  blog: {
+    copy: "Notes from building systems, games, web tools, and curious prototypes.",
+    kicker: "QUEST ARCHIVE",
+    title: "Blogs",
+    type: "Writing",
+    url: "/blog",
+  },
+});
+
+class MjjJrpgCharacter extends HTMLElement {
+  static get observedAttributes() {
+    return ["state"];
+  }
+
+  connectedCallback() {
+    this.render();
+  }
+
+  attributeChangedCallback() {
+    if (this.isConnected) this.render();
+  }
+
+  render() {
+    const image = this.querySelector("[data-character]");
+    if (!image) return;
+    const state = CHARACTER_STATES[this.getAttribute("state")] ||
+      CHARACTER_STATES.default;
+    image.src = state.src;
+    image.alt = state.alt;
+  }
+}
+
+class MjjJrpgChat extends HTMLElement {
+  connectedCallback() {
+    this._messages = this.querySelector("[data-messages]");
+    this._viewport = this.querySelector("[data-zen-viewport]");
+  }
+
+  appendMessage(speaker, text) {
+    if (!this._messages) return;
+    const item = document.createElement("li");
+    const name = document.createElement("strong");
+    const copy = document.createElement("p");
+    item.className = "jrpg-message";
+    item.dataset.speaker = speaker;
+    name.textContent = speaker;
+    copy.textContent = text;
+    item.append(name, copy);
+    this._messages.append(item);
+    requestAnimationFrame(() => {
+      if (this._viewport) {
+        this._viewport.scrollTop = this._viewport.scrollHeight;
+      }
+    });
+  }
+}
+
+class MjjJrpgComposer extends HTMLElement {
+  connectedCallback() {
+    this._form = this.querySelector("[data-composer]");
+    this._textarea = this.querySelector("textarea");
+    this._button = this.querySelector('button[type="submit"]');
+    this._onSubmit = event => {
+      event.preventDefault();
+      const message = this._textarea?.value.trim();
+      if (!message || this._button?.disabled) return;
+      this.dispatchEvent(new CustomEvent("mjj-jrpg-submit", {
+        bubbles: true,
+        detail: { message },
+      }));
+      this._form.reset();
+    };
+    this._onKeydown = event => {
+      if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
+      event.preventDefault();
+      this._form?.requestSubmit();
+    };
+    this._form?.addEventListener("submit", this._onSubmit);
+    this._textarea?.addEventListener("keydown", this._onKeydown);
+  }
+
+  disconnectedCallback() {
+    this._form?.removeEventListener("submit", this._onSubmit);
+    this._textarea?.removeEventListener("keydown", this._onKeydown);
+  }
+
+  setBusy(busy) {
+    if (this._textarea) this._textarea.disabled = busy;
+    if (this._button) {
+      this._button.disabled = busy;
+      this._button.setAttribute("aria-busy", String(busy));
+    }
+  }
+}
+
+class MjjJrpgMenu extends HTMLElement {
+  connectedCallback() {
+    this._onClick = event => {
+      const button = event.target.closest("button[data-preview]");
+      if (!button || !this.contains(button)) return;
+      for (const item of this.querySelectorAll("button[data-preview]")) {
+        item.setAttribute("aria-pressed", String(item === button));
+      }
+      this.dispatchEvent(new CustomEvent("mjj-jrpg-preview-change", {
+        bubbles: true,
+        detail: { selection: button.dataset.preview },
+      }));
+    };
+    this.addEventListener("click", this._onClick);
+  }
+
+  disconnectedCallback() {
+    this.removeEventListener("click", this._onClick);
+  }
+}
+
+class MjjJrpgPreview extends HTMLElement {
+  connectedCallback() {
+    this.show(this.dataset.selection || "resume");
+  }
+
+  show(selection) {
+    const preview = PREVIEWS[selection] || PREVIEWS.resume;
+    this.dataset.selection = selection;
+    this.querySelector("[data-preview-kicker]").textContent = preview.kicker;
+    this.querySelector("[data-preview-title]").textContent = preview.title;
+    this.querySelector("[data-preview-copy]").textContent = preview.copy;
+    this.querySelector("[data-preview-type]").textContent = preview.type;
+    this.querySelector("[data-dialog-title]").textContent = preview.title;
+    this.querySelector("[data-dialog-copy]").textContent = preview.copy;
+    const link = this.querySelector("[data-preview-link]");
+    link.href = preview.url;
+    link.setAttribute("aria-label", `Open ${preview.title}`);
+  }
+}
+
+class MjjJrpgShell extends HTMLElement {}
+
+for (const [name, constructor] of Object.entries({
+  "mjj-jrpg-character": MjjJrpgCharacter,
+  "mjj-jrpg-chat": MjjJrpgChat,
+  "mjj-jrpg-composer": MjjJrpgComposer,
+  "mjj-jrpg-menu": MjjJrpgMenu,
+  "mjj-jrpg-preview": MjjJrpgPreview,
+  "mjj-jrpg-shell": MjjJrpgShell,
+})) {
+  if (!customElements.get(name)) customElements.define(name, constructor);
+}
+
+function initializeJrpgPage() {
+  const colorProbe = document.createElement("span");
+  colorProbe.style.background = "var(--zenbu-sys-color-surface-page)";
+  document.body.append(colorProbe);
+  const themeColor = getComputedStyle(colorProbe).backgroundColor;
+  colorProbe.remove();
+  for (const meta of document.querySelectorAll('meta[name="theme-color"]')) {
+    meta.setAttribute("content", themeColor);
+  }
+
+  const shell = document.querySelector("mjj-jrpg-shell");
+  const character = shell?.querySelector("mjj-jrpg-character");
+  const chat = shell?.querySelector("mjj-jrpg-chat");
+  const composer = shell?.querySelector("mjj-jrpg-composer");
+  const preview = shell?.querySelector("mjj-jrpg-preview");
+  let characterTimer = 0;
+  let replyTimer = 0;
+
+  shell?.addEventListener("mjj-jrpg-preview-change", event => {
+    preview?.show(event.detail.selection);
+    character?.setAttribute("state", "thinking");
+    window.clearTimeout(characterTimer);
+    characterTimer = window.setTimeout(() => {
+      character?.setAttribute("state", "default");
+    }, 650);
+  });
+
+  shell?.addEventListener("mjj-jrpg-submit", event => {
+    chat?.appendMessage("June", event.detail.message);
+    composer?.setBusy(true);
+    character?.setAttribute("state", "thinking");
+    window.clearTimeout(characterTimer);
+    window.clearTimeout(replyTimer);
+    replyTimer = window.setTimeout(() => {
+      chat?.appendMessage(
+        "Epi",
+        "A promising quest. The menu has the fastest paths through this site.",
+      );
+      character?.setAttribute("state", "default");
+      composer?.setBusy(false);
+      composer?.querySelector("textarea")?.focus();
+    }, 700);
+  });
+}
+
+if (document.readyState === "loading") {
+  document.addEventListener("DOMContentLoaded", initializeJrpgPage, {
+    once: true,
+  });
+} else {
+  initializeJrpgPage();
+}