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