polats Claude Opus 4.8 (1M context) commited on
Commit
d77de70
·
1 Parent(s): cedb3f2

Tour: pulse-glow the control each step is waiting on

Browse files

While 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>

Files changed (4) hide show
  1. web/comboBattler.js +6 -2
  2. web/heroCreator.js +1 -1
  3. web/tiny.js +14 -14
  4. web/tutorial.js +33 -1
web/comboBattler.js CHANGED
@@ -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
- actionRow.append(dodgeBtn, roundBtn("\u2694", () => {
 
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");
web/heroCreator.js CHANGED
@@ -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…' })
web/tiny.js CHANGED
@@ -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
  }
web/tutorial.js CHANGED
@@ -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 }