Spaces:
Running
Tour: pulse-glow the control each step is waiting on
Browse filesWhile a tour step awaits input, the button it points to now pulses with a gold
glow so the eye is led straight to it. The glow follows the tour — it clears
from the old control and lights the new one as steps advance, and it tracks
controls that only appear once a modal/sheet opens or get re-rendered (picker
card, skill buttons) via a light poll.
- tutorial.js: inject .ta-tut-glow keyframes; trackGlow(selector) per step,
cleared on teardown; steps gain an optional `target` CSS selector.
- tiny.js: target selectors on the create/recruit/save/move/attack/dodge/sheet/
forge/talk steps; data-tut tags on the create card, Save & Play, ☰ sheet,
Forge skill and Talk buttons.
- heroCreator.js: data-tut="recruit" on the Recruit button.
- comboBattler.js: rebuilt bundle (joystick/dodge/attack now carry data-tut).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- web/comboBattler.js +6 -2
- web/heroCreator.js +1 -1
- web/tiny.js +14 -14
- web/tutorial.js +33 -1
|
@@ -6410,6 +6410,7 @@ function mountComboBattler(pixi, host, opts = {}) {
|
|
| 6410 |
wrap.style.cssText = "position:absolute;inset:0;pointer-events:none;z-index:5;font-family:monospace";
|
| 6411 |
const BASE = 116, KNOB = 54, R2 = (BASE - KNOB) / 2, DEAD = 0.22;
|
| 6412 |
const base = document.createElement("div");
|
|
|
|
| 6413 |
base.style.cssText = `position:absolute;left:20px;bottom:20px;width:${BASE}px;height:${BASE}px;border-radius:50%;background:rgba(20,24,33,.32);border:2px solid rgba(232,232,232,.32);pointer-events:auto;touch-action:none`;
|
| 6414 |
const knob = document.createElement("div");
|
| 6415 |
knob.style.cssText = `position:absolute;left:${(BASE - KNOB) / 2}px;top:${(BASE - KNOB) / 2}px;width:${KNOB}px;height:${KNOB}px;border-radius:50%;background:rgba(232,232,232,.85);border:1px solid #20262e;transition:transform .05s linear`;
|
|
@@ -6485,9 +6486,12 @@ function mountComboBattler(pixi, host, opts = {}) {
|
|
| 6485 |
req.dodge = true;
|
| 6486 |
}, false);
|
| 6487 |
dodgeBtn.title = "Dodge roll (Shift)";
|
| 6488 |
-
|
|
|
|
| 6489 |
req.attack = true;
|
| 6490 |
-
}, true)
|
|
|
|
|
|
|
| 6491 |
pad.appendChild(actionRow);
|
| 6492 |
wrap.appendChild(pad);
|
| 6493 |
threatBadge = document.createElement("div");
|
|
|
|
| 6410 |
wrap.style.cssText = "position:absolute;inset:0;pointer-events:none;z-index:5;font-family:monospace";
|
| 6411 |
const BASE = 116, KNOB = 54, R2 = (BASE - KNOB) / 2, DEAD = 0.22;
|
| 6412 |
const base = document.createElement("div");
|
| 6413 |
+
base.dataset.tut = "move";
|
| 6414 |
base.style.cssText = `position:absolute;left:20px;bottom:20px;width:${BASE}px;height:${BASE}px;border-radius:50%;background:rgba(20,24,33,.32);border:2px solid rgba(232,232,232,.32);pointer-events:auto;touch-action:none`;
|
| 6415 |
const knob = document.createElement("div");
|
| 6416 |
knob.style.cssText = `position:absolute;left:${(BASE - KNOB) / 2}px;top:${(BASE - KNOB) / 2}px;width:${KNOB}px;height:${KNOB}px;border-radius:50%;background:rgba(232,232,232,.85);border:1px solid #20262e;transition:transform .05s linear`;
|
|
|
|
| 6486 |
req.dodge = true;
|
| 6487 |
}, false);
|
| 6488 |
dodgeBtn.title = "Dodge roll (Shift)";
|
| 6489 |
+
dodgeBtn.dataset.tut = "dodge";
|
| 6490 |
+
const atkBtn = roundBtn("\u2694", () => {
|
| 6491 |
req.attack = true;
|
| 6492 |
+
}, true);
|
| 6493 |
+
atkBtn.dataset.tut = "attack";
|
| 6494 |
+
actionRow.append(dodgeBtn, atkBtn);
|
| 6495 |
pad.appendChild(actionRow);
|
| 6496 |
wrap.appendChild(pad);
|
| 6497 |
threatBadge = document.createElement("div");
|
|
@@ -147,7 +147,7 @@ export function mountHeroCreator(host, opts = {}) {
|
|
| 147 |
const seed = el('input', { class: 'persona-input', type: 'text', placeholder: 'a word, a vibe… (optional)' })
|
| 148 |
const stats = el('div', { class: 'persona-stats' })
|
| 149 |
const status = el('div', { class: 'persona-status' }, DEFAULT_STATUS)
|
| 150 |
-
const btn = el('button', { class: 'persona-go', type: 'button' }, '⚔ Recruit hero')
|
| 151 |
|
| 152 |
const nameEl = el('div', { class: 'persona-name persona-edit', 'data-ph': 'Name' })
|
| 153 |
const aboutEl = el('div', { class: 'persona-about persona-edit', 'data-ph': 'Their story…' })
|
|
|
|
| 147 |
const seed = el('input', { class: 'persona-input', type: 'text', placeholder: 'a word, a vibe… (optional)' })
|
| 148 |
const stats = el('div', { class: 'persona-stats' })
|
| 149 |
const status = el('div', { class: 'persona-status' }, DEFAULT_STATUS)
|
| 150 |
+
const btn = el('button', { class: 'persona-go', type: 'button', 'data-tut': 'recruit' }, '⚔ Recruit hero')
|
| 151 |
|
| 152 |
const nameEl = el('div', { class: 'persona-name persona-edit', 'data-ph': 'Name' })
|
| 153 |
const aboutEl = el('div', { class: 'persona-about persona-edit', 'data-ph': 'Their story…' })
|
|
@@ -28,16 +28,16 @@ import { createTutorial } from '/web/tutorial.js'
|
|
| 28 |
// First-run guided tour (in-world copy). Steps advance as the player acts; always skippable.
|
| 29 |
const TUTORIAL_STEPS = [
|
| 30 |
{ anchor: 'center', nextLabel: 'Begin', skipLabel: 'No thanks', text: 'Welcome, commander. Your champions aren’t chosen from a roster — each is forged anew, body and soul. A quick tour?' },
|
| 31 |
-
{ anchor: 'bottom-center', advanceOn: 'create-open', text: 'Recruit your first champion — tap “+ Create hero”.' },
|
| 32 |
-
{ anchor: 'center', advanceOn: 'persona', text: 'Choose a calling and Recruit. Their name and legend are written for them alone — here and now.' },
|
| 33 |
-
{ anchor: 'center', advanceOn: 'spawn', text: 'A face all their own, and a voice to match. Save & Play to bring them to the field.' },
|
| 34 |
-
{ anchor: 'bottom-left', advanceOn: 'move', text: 'Lead them with the stick — or WASD on a keyboard.' },
|
| 35 |
-
{ anchor: 'bottom-right', advanceOn: 'kill', text: 'Strike with ⚔ (or Space). Most foes fall in a blow or two — thin the ranks.' },
|
| 36 |
-
{ anchor: 'bottom-right', text: 'Roll with ⟲ (or Shift) to slip a blow — untouchable for a heartbeat.' },
|
| 37 |
{ anchor: 'top-left', text: 'Felling foes earns XP. Fill the bar to level up and draft a boon.' },
|
| 38 |
-
{ anchor: 'top-right', advanceOn: 'sheet', text: 'Press C (or the ☰ button) to open your champion’s sheet.' },
|
| 39 |
-
{ anchor: 'right', advanceOn: 'forge', text: 'Forge a skill from your own words — describe it and it’s made real, unlike any other, with art of its own. Then set it to a slot and hold 1/2/3 to aim, release to unleash — it strikes all who stand near.' },
|
| 40 |
-
{ anchor: 'right', text: 'Tap “Talk” — they remember every battle they’ve lived, and answer in their own voice (🔊).' },
|
| 41 |
{ anchor: 'center', nextLabel: 'To battle!', text: 'Recruit, fight, forge, speak. The field is yours, commander.' },
|
| 42 |
]
|
| 43 |
const tut = createTutorial(TUTORIAL_STEPS)
|
|
@@ -346,7 +346,7 @@ function buildHeroPicker(host, personas, chars, onPick, onCreate) {
|
|
| 346 |
}
|
| 347 |
// "+ Create hero" card → opens the create modal.
|
| 348 |
const create = document.createElement('button')
|
| 349 |
-
create.className = 'hero-pick-create'; create.style.cssText = PICK_CARD_CSS
|
| 350 |
const plus = document.createElement('div'); plus.className = 'hero-pick-plus'; plus.textContent = '+'
|
| 351 |
plus.style.cssText = 'width:48px;height:48px;display:flex;align-items:center;justify-content:center'
|
| 352 |
const clbl = document.createElement('div'); clbl.textContent = 'Create hero'; clbl.style.cssText = 'max-width:70px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'
|
|
@@ -370,7 +370,7 @@ function openCreateModal(host, onCreated) {
|
|
| 370 |
const body = document.createElement('div'); body.className = 'hero-modal-body'
|
| 371 |
const foot = document.createElement('div'); foot.className = 'hero-modal-foot'
|
| 372 |
const cancel = document.createElement('button'); cancel.className = 'hero-modal-cancel'; cancel.type = 'button'; cancel.textContent = 'Cancel'
|
| 373 |
-
const save = document.createElement('button'); save.className = 'hero-modal-save'; save.type = 'button'; save.textContent = 'Save & Play ▶'; save.disabled = true
|
| 374 |
foot.append(cancel, save) // the creator prepends its ← Back here (shown only once a hero exists)
|
| 375 |
card.append(head, body, foot); backdrop.append(card); host.appendChild(backdrop)
|
| 376 |
|
|
@@ -494,7 +494,7 @@ whenEl('battle-stage', async (el) => {
|
|
| 494 |
|
| 495 |
// ── Character sheet — a top-right button slides a hero panel in from the right (sidebar-style). ──
|
| 496 |
let sheetOpen = false
|
| 497 |
-
const sheetBtn = document.createElement('button'); sheetBtn.type = 'button'; sheetBtn.title = 'Character (C)'
|
| 498 |
sheetBtn.innerHTML = '☰<span style="font:700 9px var(--tac-font,system-ui);display:block;margin-top:-2px;color:#9aa4b2;letter-spacing:.04em">C</span>'
|
| 499 |
sheetBtn.style.cssText = 'position:absolute;top:14px;right:14px;z-index:7;width:44px;height:46px;border-radius:10px;border:1px solid #2a3340;background:rgba(20,24,33,.85);color:#e8e8e8;font:600 17px var(--tac-font,system-ui);cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;line-height:1'
|
| 500 |
const sheet = document.createElement('aside')
|
|
@@ -614,7 +614,7 @@ whenEl('battle-stage', async (el) => {
|
|
| 614 |
const { sh, close } = openToolSheet(`⚒ Forge a skill for ${currentHero.name || 'this hero'}`)
|
| 615 |
const ta = document.createElement('textarea'); ta.rows = 3; ta.placeholder = 'describe the skill (e.g. “a whirlwind that bleeds everyone around me”)'
|
| 616 |
ta.style.cssText = 'width:100%;resize:vertical;background:#0d1015;border:1px solid #2a3340;border-radius:10px;color:#e8e8e8;padding:10px;font:13px var(--tac-font,system-ui);box-sizing:border-box'
|
| 617 |
-
const go = document.createElement('button'); go.type = 'button'; go.textContent = '⚒ Forge skill'
|
| 618 |
go.style.cssText = 'width:100%;margin-top:10px;padding:12px;border-radius:10px;border:1px solid #c9a227;background:#2a2410;color:#ffe082;font:600 14px var(--tac-font,system-ui);cursor:pointer'
|
| 619 |
const status = document.createElement('div'); status.style.cssText = 'color:#9aa4b2;font-size:12px;margin-top:8px;min-height:16px'
|
| 620 |
sh.append(ta, go, status); setTimeout(() => ta.focus(), 50)
|
|
@@ -663,7 +663,7 @@ whenEl('battle-stage', async (el) => {
|
|
| 663 |
}
|
| 664 |
// Talk to this fighter (chat + voice). Needs a saved hero (id) to persist the conversation.
|
| 665 |
if (currentHero.id) {
|
| 666 |
-
const talk = document.createElement('button'); talk.type = 'button'; talk.textContent = `💬 Talk to ${currentHero.name || 'them'}`
|
| 667 |
talk.style.cssText = 'width:100%;padding:11px;border-radius:10px;border:1px solid #2a3340;background:#16202e;color:#e8e8e8;font:600 13px var(--tac-font,system-ui);cursor:pointer;margin-bottom:12px'
|
| 668 |
talk.addEventListener('click', openChat); sheet.append(talk)
|
| 669 |
}
|
|
|
|
| 28 |
// First-run guided tour (in-world copy). Steps advance as the player acts; always skippable.
|
| 29 |
const TUTORIAL_STEPS = [
|
| 30 |
{ anchor: 'center', nextLabel: 'Begin', skipLabel: 'No thanks', text: 'Welcome, commander. Your champions aren’t chosen from a roster — each is forged anew, body and soul. A quick tour?' },
|
| 31 |
+
{ anchor: 'bottom-center', advanceOn: 'create-open', target: '[data-tut="create"]', text: 'Recruit your first champion — tap “+ Create hero”.' },
|
| 32 |
+
{ anchor: 'center', advanceOn: 'persona', target: '[data-tut="recruit"]', text: 'Choose a calling and Recruit. Their name and legend are written for them alone — here and now.' },
|
| 33 |
+
{ anchor: 'center', advanceOn: 'spawn', target: '[data-tut="save"]', text: 'A face all their own, and a voice to match. Save & Play to bring them to the field.' },
|
| 34 |
+
{ anchor: 'bottom-left', advanceOn: 'move', target: '[data-tut="move"]', text: 'Lead them with the stick — or WASD on a keyboard.' },
|
| 35 |
+
{ anchor: 'bottom-right', advanceOn: 'kill', target: '[data-tut="attack"]', text: 'Strike with ⚔ (or Space). Most foes fall in a blow or two — thin the ranks.' },
|
| 36 |
+
{ anchor: 'bottom-right', target: '[data-tut="dodge"]', text: 'Roll with ⟲ (or Shift) to slip a blow — untouchable for a heartbeat.' },
|
| 37 |
{ anchor: 'top-left', text: 'Felling foes earns XP. Fill the bar to level up and draft a boon.' },
|
| 38 |
+
{ anchor: 'top-right', advanceOn: 'sheet', target: '[data-tut="sheet"]', text: 'Press C (or the ☰ button) to open your champion’s sheet.' },
|
| 39 |
+
{ anchor: 'right', advanceOn: 'forge', target: '[data-tut="forge"]', text: 'Forge a skill from your own words — describe it and it’s made real, unlike any other, with art of its own. Then set it to a slot and hold 1/2/3 to aim, release to unleash — it strikes all who stand near.' },
|
| 40 |
+
{ anchor: 'right', target: '[data-tut="talk"]', text: 'Tap “Talk” — they remember every battle they’ve lived, and answer in their own voice (🔊).' },
|
| 41 |
{ anchor: 'center', nextLabel: 'To battle!', text: 'Recruit, fight, forge, speak. The field is yours, commander.' },
|
| 42 |
]
|
| 43 |
const tut = createTutorial(TUTORIAL_STEPS)
|
|
|
|
| 346 |
}
|
| 347 |
// "+ Create hero" card → opens the create modal.
|
| 348 |
const create = document.createElement('button')
|
| 349 |
+
create.className = 'hero-pick-create'; create.dataset.tut = 'create'; create.style.cssText = PICK_CARD_CSS
|
| 350 |
const plus = document.createElement('div'); plus.className = 'hero-pick-plus'; plus.textContent = '+'
|
| 351 |
plus.style.cssText = 'width:48px;height:48px;display:flex;align-items:center;justify-content:center'
|
| 352 |
const clbl = document.createElement('div'); clbl.textContent = 'Create hero'; clbl.style.cssText = 'max-width:70px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'
|
|
|
|
| 370 |
const body = document.createElement('div'); body.className = 'hero-modal-body'
|
| 371 |
const foot = document.createElement('div'); foot.className = 'hero-modal-foot'
|
| 372 |
const cancel = document.createElement('button'); cancel.className = 'hero-modal-cancel'; cancel.type = 'button'; cancel.textContent = 'Cancel'
|
| 373 |
+
const save = document.createElement('button'); save.className = 'hero-modal-save'; save.dataset.tut = 'save'; save.type = 'button'; save.textContent = 'Save & Play ▶'; save.disabled = true
|
| 374 |
foot.append(cancel, save) // the creator prepends its ← Back here (shown only once a hero exists)
|
| 375 |
card.append(head, body, foot); backdrop.append(card); host.appendChild(backdrop)
|
| 376 |
|
|
|
|
| 494 |
|
| 495 |
// ── Character sheet — a top-right button slides a hero panel in from the right (sidebar-style). ──
|
| 496 |
let sheetOpen = false
|
| 497 |
+
const sheetBtn = document.createElement('button'); sheetBtn.type = 'button'; sheetBtn.title = 'Character (C)'; sheetBtn.dataset.tut = 'sheet'
|
| 498 |
sheetBtn.innerHTML = '☰<span style="font:700 9px var(--tac-font,system-ui);display:block;margin-top:-2px;color:#9aa4b2;letter-spacing:.04em">C</span>'
|
| 499 |
sheetBtn.style.cssText = 'position:absolute;top:14px;right:14px;z-index:7;width:44px;height:46px;border-radius:10px;border:1px solid #2a3340;background:rgba(20,24,33,.85);color:#e8e8e8;font:600 17px var(--tac-font,system-ui);cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;line-height:1'
|
| 500 |
const sheet = document.createElement('aside')
|
|
|
|
| 614 |
const { sh, close } = openToolSheet(`⚒ Forge a skill for ${currentHero.name || 'this hero'}`)
|
| 615 |
const ta = document.createElement('textarea'); ta.rows = 3; ta.placeholder = 'describe the skill (e.g. “a whirlwind that bleeds everyone around me”)'
|
| 616 |
ta.style.cssText = 'width:100%;resize:vertical;background:#0d1015;border:1px solid #2a3340;border-radius:10px;color:#e8e8e8;padding:10px;font:13px var(--tac-font,system-ui);box-sizing:border-box'
|
| 617 |
+
const go = document.createElement('button'); go.type = 'button'; go.textContent = '⚒ Forge skill'; go.dataset.tut = 'forge'
|
| 618 |
go.style.cssText = 'width:100%;margin-top:10px;padding:12px;border-radius:10px;border:1px solid #c9a227;background:#2a2410;color:#ffe082;font:600 14px var(--tac-font,system-ui);cursor:pointer'
|
| 619 |
const status = document.createElement('div'); status.style.cssText = 'color:#9aa4b2;font-size:12px;margin-top:8px;min-height:16px'
|
| 620 |
sh.append(ta, go, status); setTimeout(() => ta.focus(), 50)
|
|
|
|
| 663 |
}
|
| 664 |
// Talk to this fighter (chat + voice). Needs a saved hero (id) to persist the conversation.
|
| 665 |
if (currentHero.id) {
|
| 666 |
+
const talk = document.createElement('button'); talk.type = 'button'; talk.dataset.tut = 'talk'; talk.textContent = `💬 Talk to ${currentHero.name || 'them'}`
|
| 667 |
talk.style.cssText = 'width:100%;padding:11px;border-radius:10px;border:1px solid #2a3340;background:#16202e;color:#e8e8e8;font:600 13px var(--tac-font,system-ui);cursor:pointer;margin-bottom:12px'
|
| 668 |
talk.addEventListener('click', openChat); sheet.append(talk)
|
| 669 |
}
|
|
@@ -8,6 +8,17 @@ const save = (s) => { try { localStorage.setItem(KEY, JSON.stringify(s)) } catch
|
|
| 8 |
|
| 9 |
function elc(tag, css, text) { const n = document.createElement(tag); if (css) n.style.cssText = css; if (text != null) n.textContent = text; return n }
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
// Place a fixed-position callout in a screen region, offset so it doesn't cover the control.
|
| 12 |
// All anchors sit in the LOWER part of the screen so the callout never covers the map, the create
|
| 13 |
// modal, the picker or the top UI — and you can still read + click through. 'bottom' is the default
|
|
@@ -21,8 +32,28 @@ const REGION = { center: BANNER, bottom: BANNER, 'bottom-center': BANNER, 'botto
|
|
| 21 |
export function createTutorial(steps) {
|
| 22 |
let state = { step: 0, done: false, dismissed: false, ...load() }
|
| 23 |
let idx = -1, box = null
|
|
|
|
| 24 |
const persist = () => save(state)
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
const shouldAutoStart = () => !state.done && !state.dismissed
|
| 27 |
function startOrResume() { if (state.done) return; idx = Math.min(state.step || 0, steps.length - 1); show() }
|
| 28 |
function emit(event) { const s = steps[idx]; if (idx >= 0 && s && s.advanceOn === event) advance() }
|
|
@@ -30,7 +61,7 @@ export function createTutorial(steps) {
|
|
| 30 |
function skip() { state.dismissed = true; persist(); teardown() }
|
| 31 |
function finish() { state.done = true; persist(); teardown() }
|
| 32 |
function reset() { state = { step: 0, done: false, dismissed: false }; persist(); idx = 0; show() }
|
| 33 |
-
function teardown() { if (box) { try { box.remove() } catch { /* ignore */ } } box = null }
|
| 34 |
|
| 35 |
function show() {
|
| 36 |
const s = steps[idx]
|
|
@@ -51,6 +82,7 @@ export function createTutorial(steps) {
|
|
| 51 |
nx.type = 'button'; nx.addEventListener('click', advance); row.appendChild(nx)
|
| 52 |
box.appendChild(row)
|
| 53 |
document.body.appendChild(box)
|
|
|
|
| 54 |
}
|
| 55 |
|
| 56 |
return { emit, startOrResume, shouldAutoStart, reset, skip, isActive: () => !!box }
|
|
|
|
| 8 |
|
| 9 |
function elc(tag, css, text) { const n = document.createElement(tag); if (css) n.style.cssText = css; if (text != null) n.textContent = text; return n }
|
| 10 |
|
| 11 |
+
// Pulsing gold glow drawn on whatever control the current step is waiting for, so the eye is led
|
| 12 |
+
// straight to the button to press. Injected once; targets carry a [data-tut="…"] attribute.
|
| 13 |
+
;(function injectTutGlow() {
|
| 14 |
+
try {
|
| 15 |
+
if (document.getElementById('ta-tut-glow-css')) return
|
| 16 |
+
const s = document.createElement('style'); s.id = 'ta-tut-glow-css'
|
| 17 |
+
s.textContent = '@keyframes ta-tut-pulse{0%,100%{box-shadow:0 0 0 2px rgba(255,224,130,.55),0 0 8px 2px rgba(201,162,39,.35)}50%{box-shadow:0 0 0 3px rgba(255,224,130,.95),0 0 22px 8px rgba(255,224,130,.7)}}.ta-tut-glow{animation:ta-tut-pulse 1.15s ease-in-out infinite;border-color:#ffe082!important;position:relative;z-index:9}'
|
| 18 |
+
document.head.appendChild(s)
|
| 19 |
+
} catch { /* ignore */ }
|
| 20 |
+
})()
|
| 21 |
+
|
| 22 |
// Place a fixed-position callout in a screen region, offset so it doesn't cover the control.
|
| 23 |
// All anchors sit in the LOWER part of the screen so the callout never covers the map, the create
|
| 24 |
// modal, the picker or the top UI — and you can still read + click through. 'bottom' is the default
|
|
|
|
| 32 |
export function createTutorial(steps) {
|
| 33 |
let state = { step: 0, done: false, dismissed: false, ...load() }
|
| 34 |
let idx = -1, box = null
|
| 35 |
+
let glowEl = null, glowTimer = null
|
| 36 |
const persist = () => save(state)
|
| 37 |
|
| 38 |
+
// Keep a pulsing glow on the step's target control. Polled because some targets only appear once a
|
| 39 |
+
// modal/sheet opens, and some get re-rendered (the picker card, skill buttons) — we follow the swap.
|
| 40 |
+
function clearGlow() {
|
| 41 |
+
if (glowTimer) { clearInterval(glowTimer); glowTimer = null }
|
| 42 |
+
if (glowEl) { try { glowEl.classList.remove('ta-tut-glow') } catch { /* ignore */ } glowEl = null }
|
| 43 |
+
}
|
| 44 |
+
function trackGlow(sel) {
|
| 45 |
+
clearGlow()
|
| 46 |
+
if (!sel) return
|
| 47 |
+
const tick = () => {
|
| 48 |
+
const el = document.querySelector(sel)
|
| 49 |
+
if (el === glowEl) return
|
| 50 |
+
if (glowEl) { try { glowEl.classList.remove('ta-tut-glow') } catch { /* ignore */ } }
|
| 51 |
+
glowEl = el
|
| 52 |
+
if (el) { try { el.classList.add('ta-tut-glow') } catch { /* ignore */ } }
|
| 53 |
+
}
|
| 54 |
+
tick(); glowTimer = setInterval(tick, 400)
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
const shouldAutoStart = () => !state.done && !state.dismissed
|
| 58 |
function startOrResume() { if (state.done) return; idx = Math.min(state.step || 0, steps.length - 1); show() }
|
| 59 |
function emit(event) { const s = steps[idx]; if (idx >= 0 && s && s.advanceOn === event) advance() }
|
|
|
|
| 61 |
function skip() { state.dismissed = true; persist(); teardown() }
|
| 62 |
function finish() { state.done = true; persist(); teardown() }
|
| 63 |
function reset() { state = { step: 0, done: false, dismissed: false }; persist(); idx = 0; show() }
|
| 64 |
+
function teardown() { clearGlow(); if (box) { try { box.remove() } catch { /* ignore */ } } box = null }
|
| 65 |
|
| 66 |
function show() {
|
| 67 |
const s = steps[idx]
|
|
|
|
| 82 |
nx.type = 'button'; nx.addEventListener('click', advance); row.appendChild(nx)
|
| 83 |
box.appendChild(row)
|
| 84 |
document.body.appendChild(box)
|
| 85 |
+
trackGlow(s.target) // pulse the control this step is pointing at (if any)
|
| 86 |
}
|
| 87 |
|
| 88 |
return { emit, startOrResume, shouldAutoStart, reset, skip, isActive: () => !!box }
|