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 }