feat: wire NPC + art trays and prop viewer into the shell
This commit is contained in:
@@ -27,6 +27,8 @@ export class DirectorRail extends HTMLElement {
|
||||
<div class="cell trig"><span class="k">Next hard trigger</span><span class="v" data-role="next">${nhText}</span></div>
|
||||
<button class="reached" data-role="reached">✓ Reached it</button>
|
||||
<button class="tray-btn" data-role="open-dice" aria-label="Dice">🎲</button>
|
||||
<button class="tray-btn" data-role="open-npc" aria-label="NPC">👤</button>
|
||||
<button class="tray-btn" data-role="open-art" aria-label="Art">✏️</button>
|
||||
</div>`;
|
||||
|
||||
this.querySelector('[data-role=reached]').addEventListener('click', () => {
|
||||
@@ -35,6 +37,12 @@ export class DirectorRail extends HTMLElement {
|
||||
this.querySelector('[data-role=open-dice]').addEventListener('click', () => {
|
||||
this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'dice' }, bubbles: true }));
|
||||
});
|
||||
this.querySelector('[data-role=open-npc]').addEventListener('click', () => {
|
||||
this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'npc' }, bubbles: true }));
|
||||
});
|
||||
this.querySelector('[data-role=open-art]').addEventListener('click', () => {
|
||||
this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'art' }, bubbles: true }));
|
||||
});
|
||||
}
|
||||
}
|
||||
customElements.define('director-rail', DirectorRail);
|
||||
|
||||
@@ -3,6 +3,9 @@ import { initialClock, start, elapsedMs } from '../core/clock.js';
|
||||
import { nextUnreachedBeat } from '../core/timeline.js';
|
||||
import './director-rail.js';
|
||||
import './dice-tray.js';
|
||||
import './npc-tray.js';
|
||||
import './art-tray.js';
|
||||
import './prop-viewer.js';
|
||||
|
||||
export class GmShell extends HTMLElement {
|
||||
constructor() {
|
||||
@@ -12,15 +15,23 @@ export class GmShell extends HTMLElement {
|
||||
this.store = null;
|
||||
this.clockState = initialClock();
|
||||
this.stamps = {};
|
||||
this.cast = [];
|
||||
this._tick = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.innerHTML = `
|
||||
<director-rail></director-rail>
|
||||
<main class="stage"><dice-tray hidden></dice-tray></main>`;
|
||||
<main class="stage">
|
||||
<dice-tray hidden></dice-tray>
|
||||
<npc-tray hidden></npc-tray>
|
||||
<art-tray hidden></art-tray>
|
||||
</main>
|
||||
<prop-viewer></prop-viewer>`;
|
||||
this.addEventListener('reached', () => this.onReached());
|
||||
this.addEventListener('open-tool', (e) => this.onOpenTool(e));
|
||||
this.addEventListener('keep-npc', (e) => this.onKeepNpc(e));
|
||||
this.addEventListener('show-prop', (e) => this.onShowProp(e));
|
||||
}
|
||||
|
||||
loadScenario(scenario) {
|
||||
@@ -28,6 +39,7 @@ export class GmShell extends HTMLElement {
|
||||
this.store = createStore(scenario.meta.id);
|
||||
this.clockState = this.store.get('clock', initialClock());
|
||||
this.stamps = this.store.get('stamps', {});
|
||||
this.cast = this.store.get('cast', []);
|
||||
this.querySelector('dice-tray').systemId = scenario.meta.system;
|
||||
this.refresh();
|
||||
}
|
||||
@@ -50,10 +62,22 @@ export class GmShell extends HTMLElement {
|
||||
}
|
||||
|
||||
onOpenTool(e) {
|
||||
if (e.detail?.tool === 'dice') {
|
||||
const tray = this.querySelector('dice-tray');
|
||||
tray.hidden = !tray.hidden;
|
||||
}
|
||||
const tool = e.detail?.tool;
|
||||
const trays = { dice: 'dice-tray', npc: 'npc-tray', art: 'art-tray' };
|
||||
if (!(tool in trays)) return;
|
||||
const target = this.querySelector(trays[tool]);
|
||||
const willShow = target.hidden;
|
||||
for (const sel of Object.values(trays)) this.querySelector(sel).hidden = true;
|
||||
target.hidden = !willShow;
|
||||
}
|
||||
|
||||
onKeepNpc(e) {
|
||||
this.cast = [...this.cast, e.detail];
|
||||
this.store.set('cast', this.cast);
|
||||
}
|
||||
|
||||
onShowProp(e) {
|
||||
this.querySelector('prop-viewer').show(e.detail.src, e.detail.label);
|
||||
}
|
||||
|
||||
refresh() {
|
||||
|
||||
@@ -30,3 +30,28 @@ gm-shell { display: block; }
|
||||
.rolled:nth-child(2) { background: #b7c7d6; } .rolled:nth-child(3) { background: #e7c99a; }
|
||||
.verdict { font-weight: 700; color: #ffd479; min-height: 20px; }
|
||||
.roll { border: none; padding: 12px; border-radius: 9px; background: #1c8a5a; color: #eafff4; font-weight: 700; }
|
||||
|
||||
/* Slice 2 tray tools */
|
||||
.npc-tray, .art-tray { position: absolute; top: 0; left: 0; right: 0; background: #0f141b;
|
||||
border-bottom: 1px solid #2b3644; padding: 14px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.npc-tray[hidden], .art-tray[hidden] { display: none; }
|
||||
.pills { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.pill { font-size: 12px; padding: 5px 11px; border-radius: 20px; border: 1px solid #2b3644; background: #161d27; color: #9fb0c2; }
|
||||
.pill.on { background: #1e3a52; border-color: #3f7fb0; color: #dcefff; }
|
||||
.seed, .art-query { background: #161d27; border: 1px solid #2a3542; border-radius: 8px; padding: 9px 11px; color: #e7eef6; font-size: 13px; }
|
||||
.generate { border: none; padding: 10px; border-radius: 9px; background: #1c8a5a; color: #eafff4; font-weight: 700; }
|
||||
.npc-card { background: #12171f; border: 1px solid #263041; border-radius: 10px; padding: 12px; }
|
||||
.npc-name { font-size: 15px; font-weight: 700; } .npc-seed { font-size: 11px; color: #8fa1b4; margin-bottom: 8px; }
|
||||
.npc-card dl { margin: 0; display: grid; grid-template-columns: 64px 1fr; gap: 4px 10px; font-size: 12px; }
|
||||
.npc-card dt { color: #6b7a8d; text-transform: uppercase; font-size: 9px; letter-spacing: .1em; padding-top: 2px; }
|
||||
.npc-card dd { margin: 0; color: #d6e0ea; } .npc-card dd.secret { color: #ff9c9c; }
|
||||
.npc-actions { display: flex; gap: 8px; margin-top: 10px; } .npc-btn { flex: 1; padding: 9px; border: none; border-radius: 8px; background: #212a35; color: #aebccb; font-weight: 700; }
|
||||
.npc-btn.keep { background: #1c8a5a; color: #eafff4; }
|
||||
.art-results { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.thumb { border: 1px solid #2a3542; border-radius: 8px; background: #141b24; padding: 0; overflow: hidden; position: relative; }
|
||||
.thumb img { width: 100%; aspect-ratio: 3/4; object-fit: cover; display: block; }
|
||||
.thumb-cap { position: absolute; bottom: 0; left: 0; right: 0; font-size: 9px; background: #0d1117cc; color: #cfe0f0; padding: 2px 4px; }
|
||||
.art-empty { color: #6b7a8d; font-size: 12px; padding: 10px; }
|
||||
.prop-overlay { position: fixed; inset: 0; background: #000000ee; display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.prop-img { max-width: 96vw; max-height: 96vh; object-fit: contain; }
|
||||
prop-viewer[hidden] { display: none; }
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import '../../src/components/gm-shell.js';
|
||||
import afterimage from '../../src/scenarios/afterimage.js';
|
||||
|
||||
describe('<gm-shell> slice 2 tools', () => {
|
||||
let el;
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = '';
|
||||
el = document.createElement('gm-shell');
|
||||
el.now = () => 0;
|
||||
document.body.appendChild(el);
|
||||
el.loadScenario(afterimage);
|
||||
});
|
||||
|
||||
it('mounts the npc, art, and prop-viewer elements (trays hidden)', () => {
|
||||
expect(el.querySelector('npc-tray').hidden).toBe(true);
|
||||
expect(el.querySelector('art-tray').hidden).toBe(true);
|
||||
expect(el.querySelector('prop-viewer')).not.toBe(null);
|
||||
});
|
||||
|
||||
it('open-tool reveals one tray and hides the others', () => {
|
||||
el.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'npc' }, bubbles: true }));
|
||||
expect(el.querySelector('npc-tray').hidden).toBe(false);
|
||||
expect(el.querySelector('dice-tray').hidden).toBe(true);
|
||||
el.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'art' }, bubbles: true }));
|
||||
expect(el.querySelector('art-tray').hidden).toBe(false);
|
||||
expect(el.querySelector('npc-tray').hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('keep-npc appends to cast and persists under the scenario namespace', () => {
|
||||
const npc = { name: 'Otto Brandt', genre: 'Noir', seed: 'foreman', look: 'x', manner: 'y', wants: 'z', secret: 's', voice: 'v' };
|
||||
el.dispatchEvent(new CustomEvent('keep-npc', { detail: npc, bubbles: true }));
|
||||
expect(el.cast).toHaveLength(1);
|
||||
expect(el.cast[0].name).toBe('Otto Brandt');
|
||||
const persisted = JSON.parse(localStorage.getItem('gmd.afterimage.cast'));
|
||||
expect(persisted[0].name).toBe('Otto Brandt');
|
||||
});
|
||||
|
||||
it('show-prop opens the prop-viewer with the given image', () => {
|
||||
el.dispatchEvent(new CustomEvent('show-prop', { detail: { src: '/art/rain-pier.png', label: 'Pier' }, bubbles: true }));
|
||||
const viewer = el.querySelector('prop-viewer');
|
||||
expect(viewer.isOpen()).toBe(true);
|
||||
expect(viewer.querySelector('[data-role=prop-img]').getAttribute('src')).toBe('/art/rain-pier.png');
|
||||
});
|
||||
|
||||
it('the rail exposes npc and art chips that emit open-tool', () => {
|
||||
const rail = el.querySelector('director-rail');
|
||||
let tool = null;
|
||||
el.addEventListener('open-tool', (e) => { tool = e.detail.tool; });
|
||||
rail.querySelector('[data-role=open-npc]').click();
|
||||
expect(tool).toBe('npc');
|
||||
rail.querySelector('[data-role=open-art]').click();
|
||||
expect(tool).toBe('art');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user