view 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 source

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();
}