Mercurial
comparison 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 |
comparison
equal
deleted
inserted
replaced
| 258:60a876c4587a | 259:667156fcd3e3 |
|---|---|
| 1 const CHARACTER_STATES = Object.freeze({ | |
| 2 default: { | |
| 3 alt: "Epi the Shiba Inu standing happily", | |
| 4 src: "/public/jrpg/shiba-default.webp", | |
| 5 }, | |
| 6 sad: { | |
| 7 alt: "Epi the Shiba Inu sitting sadly", | |
| 8 src: "/public/jrpg/shiba-sad.webp", | |
| 9 }, | |
| 10 thinking: { | |
| 11 alt: "Epi the Shiba Inu tilting her head in thought", | |
| 12 src: "/public/jrpg/shiba-thinking.webp", | |
| 13 }, | |
| 14 }); | |
| 15 | |
| 16 const PREVIEWS = Object.freeze({ | |
| 17 resume: { | |
| 18 copy: "Experience, projects, and the systems I have helped build.", | |
| 19 kicker: "CHARACTER RECORD", | |
| 20 title: "Resume", | |
| 21 type: "Profile", | |
| 22 url: "/resume", | |
| 23 }, | |
| 24 tools: { | |
| 25 copy: "Small, focused utilities for writing, media, and experimentation.", | |
| 26 kicker: "ITEM INVENTORY", | |
| 27 title: "Tools", | |
| 28 type: "Utilities", | |
| 29 url: "/tools", | |
| 30 }, | |
| 31 blog: { | |
| 32 copy: "Notes from building systems, games, web tools, and curious prototypes.", | |
| 33 kicker: "QUEST ARCHIVE", | |
| 34 title: "Blogs", | |
| 35 type: "Writing", | |
| 36 url: "/blog", | |
| 37 }, | |
| 38 }); | |
| 39 | |
| 40 class MjjJrpgCharacter extends HTMLElement { | |
| 41 static get observedAttributes() { | |
| 42 return ["state"]; | |
| 43 } | |
| 44 | |
| 45 connectedCallback() { | |
| 46 this.render(); | |
| 47 } | |
| 48 | |
| 49 attributeChangedCallback() { | |
| 50 if (this.isConnected) this.render(); | |
| 51 } | |
| 52 | |
| 53 render() { | |
| 54 const image = this.querySelector("[data-character]"); | |
| 55 if (!image) return; | |
| 56 const state = CHARACTER_STATES[this.getAttribute("state")] || | |
| 57 CHARACTER_STATES.default; | |
| 58 image.src = state.src; | |
| 59 image.alt = state.alt; | |
| 60 } | |
| 61 } | |
| 62 | |
| 63 class MjjJrpgChat extends HTMLElement { | |
| 64 connectedCallback() { | |
| 65 this._messages = this.querySelector("[data-messages]"); | |
| 66 this._viewport = this.querySelector("[data-zen-viewport]"); | |
| 67 } | |
| 68 | |
| 69 appendMessage(speaker, text) { | |
| 70 if (!this._messages) return; | |
| 71 const item = document.createElement("li"); | |
| 72 const name = document.createElement("strong"); | |
| 73 const copy = document.createElement("p"); | |
| 74 item.className = "jrpg-message"; | |
| 75 item.dataset.speaker = speaker; | |
| 76 name.textContent = speaker; | |
| 77 copy.textContent = text; | |
| 78 item.append(name, copy); | |
| 79 this._messages.append(item); | |
| 80 requestAnimationFrame(() => { | |
| 81 if (this._viewport) { | |
| 82 this._viewport.scrollTop = this._viewport.scrollHeight; | |
| 83 } | |
| 84 }); | |
| 85 } | |
| 86 } | |
| 87 | |
| 88 class MjjJrpgComposer extends HTMLElement { | |
| 89 connectedCallback() { | |
| 90 this._form = this.querySelector("[data-composer]"); | |
| 91 this._textarea = this.querySelector("textarea"); | |
| 92 this._button = this.querySelector('button[type="submit"]'); | |
| 93 this._onSubmit = event => { | |
| 94 event.preventDefault(); | |
| 95 const message = this._textarea?.value.trim(); | |
| 96 if (!message || this._button?.disabled) return; | |
| 97 this.dispatchEvent(new CustomEvent("mjj-jrpg-submit", { | |
| 98 bubbles: true, | |
| 99 detail: { message }, | |
| 100 })); | |
| 101 this._form.reset(); | |
| 102 }; | |
| 103 this._onKeydown = event => { | |
| 104 if (event.key !== "Enter" || event.shiftKey || event.isComposing) return; | |
| 105 event.preventDefault(); | |
| 106 this._form?.requestSubmit(); | |
| 107 }; | |
| 108 this._form?.addEventListener("submit", this._onSubmit); | |
| 109 this._textarea?.addEventListener("keydown", this._onKeydown); | |
| 110 } | |
| 111 | |
| 112 disconnectedCallback() { | |
| 113 this._form?.removeEventListener("submit", this._onSubmit); | |
| 114 this._textarea?.removeEventListener("keydown", this._onKeydown); | |
| 115 } | |
| 116 | |
| 117 setBusy(busy) { | |
| 118 if (this._textarea) this._textarea.disabled = busy; | |
| 119 if (this._button) { | |
| 120 this._button.disabled = busy; | |
| 121 this._button.setAttribute("aria-busy", String(busy)); | |
| 122 } | |
| 123 } | |
| 124 } | |
| 125 | |
| 126 class MjjJrpgMenu extends HTMLElement { | |
| 127 connectedCallback() { | |
| 128 this._onClick = event => { | |
| 129 const button = event.target.closest("button[data-preview]"); | |
| 130 if (!button || !this.contains(button)) return; | |
| 131 for (const item of this.querySelectorAll("button[data-preview]")) { | |
| 132 item.setAttribute("aria-pressed", String(item === button)); | |
| 133 } | |
| 134 this.dispatchEvent(new CustomEvent("mjj-jrpg-preview-change", { | |
| 135 bubbles: true, | |
| 136 detail: { selection: button.dataset.preview }, | |
| 137 })); | |
| 138 }; | |
| 139 this.addEventListener("click", this._onClick); | |
| 140 } | |
| 141 | |
| 142 disconnectedCallback() { | |
| 143 this.removeEventListener("click", this._onClick); | |
| 144 } | |
| 145 } | |
| 146 | |
| 147 class MjjJrpgPreview extends HTMLElement { | |
| 148 connectedCallback() { | |
| 149 this.show(this.dataset.selection || "resume"); | |
| 150 } | |
| 151 | |
| 152 show(selection) { | |
| 153 const preview = PREVIEWS[selection] || PREVIEWS.resume; | |
| 154 this.dataset.selection = selection; | |
| 155 this.querySelector("[data-preview-kicker]").textContent = preview.kicker; | |
| 156 this.querySelector("[data-preview-title]").textContent = preview.title; | |
| 157 this.querySelector("[data-preview-copy]").textContent = preview.copy; | |
| 158 this.querySelector("[data-preview-type]").textContent = preview.type; | |
| 159 this.querySelector("[data-dialog-title]").textContent = preview.title; | |
| 160 this.querySelector("[data-dialog-copy]").textContent = preview.copy; | |
| 161 const link = this.querySelector("[data-preview-link]"); | |
| 162 link.href = preview.url; | |
| 163 link.setAttribute("aria-label", `Open ${preview.title}`); | |
| 164 } | |
| 165 } | |
| 166 | |
| 167 class MjjJrpgShell extends HTMLElement {} | |
| 168 | |
| 169 for (const [name, constructor] of Object.entries({ | |
| 170 "mjj-jrpg-character": MjjJrpgCharacter, | |
| 171 "mjj-jrpg-chat": MjjJrpgChat, | |
| 172 "mjj-jrpg-composer": MjjJrpgComposer, | |
| 173 "mjj-jrpg-menu": MjjJrpgMenu, | |
| 174 "mjj-jrpg-preview": MjjJrpgPreview, | |
| 175 "mjj-jrpg-shell": MjjJrpgShell, | |
| 176 })) { | |
| 177 if (!customElements.get(name)) customElements.define(name, constructor); | |
| 178 } | |
| 179 | |
| 180 function initializeJrpgPage() { | |
| 181 const colorProbe = document.createElement("span"); | |
| 182 colorProbe.style.background = "var(--zenbu-sys-color-surface-page)"; | |
| 183 document.body.append(colorProbe); | |
| 184 const themeColor = getComputedStyle(colorProbe).backgroundColor; | |
| 185 colorProbe.remove(); | |
| 186 for (const meta of document.querySelectorAll('meta[name="theme-color"]')) { | |
| 187 meta.setAttribute("content", themeColor); | |
| 188 } | |
| 189 | |
| 190 const shell = document.querySelector("mjj-jrpg-shell"); | |
| 191 const character = shell?.querySelector("mjj-jrpg-character"); | |
| 192 const chat = shell?.querySelector("mjj-jrpg-chat"); | |
| 193 const composer = shell?.querySelector("mjj-jrpg-composer"); | |
| 194 const preview = shell?.querySelector("mjj-jrpg-preview"); | |
| 195 let characterTimer = 0; | |
| 196 let replyTimer = 0; | |
| 197 | |
| 198 shell?.addEventListener("mjj-jrpg-preview-change", event => { | |
| 199 preview?.show(event.detail.selection); | |
| 200 character?.setAttribute("state", "thinking"); | |
| 201 window.clearTimeout(characterTimer); | |
| 202 characterTimer = window.setTimeout(() => { | |
| 203 character?.setAttribute("state", "default"); | |
| 204 }, 650); | |
| 205 }); | |
| 206 | |
| 207 shell?.addEventListener("mjj-jrpg-submit", event => { | |
| 208 chat?.appendMessage("June", event.detail.message); | |
| 209 composer?.setBusy(true); | |
| 210 character?.setAttribute("state", "thinking"); | |
| 211 window.clearTimeout(characterTimer); | |
| 212 window.clearTimeout(replyTimer); | |
| 213 replyTimer = window.setTimeout(() => { | |
| 214 chat?.appendMessage( | |
| 215 "Epi", | |
| 216 "A promising quest. The menu has the fastest paths through this site.", | |
| 217 ); | |
| 218 character?.setAttribute("state", "default"); | |
| 219 composer?.setBusy(false); | |
| 220 composer?.querySelector("textarea")?.focus(); | |
| 221 }, 700); | |
| 222 }); | |
| 223 } | |
| 224 | |
| 225 if (document.readyState === "loading") { | |
| 226 document.addEventListener("DOMContentLoaded", initializeJrpgPage, { | |
| 227 once: true, | |
| 228 }); | |
| 229 } else { | |
| 230 initializeJrpgPage(); | |
| 231 } |