feat: wire clue safety-net into shell + rail; add example scenario
This commit is contained in:
@@ -29,6 +29,7 @@ export class DirectorRail extends HTMLElement {
|
||||
<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>
|
||||
<button class="tray-btn" data-role="open-clues" aria-label="Clues">🔍</button>
|
||||
</div>`;
|
||||
|
||||
this.querySelector('[data-role=reached]').addEventListener('click', () => {
|
||||
@@ -43,6 +44,9 @@ export class DirectorRail extends HTMLElement {
|
||||
this.querySelector('[data-role=open-art]').addEventListener('click', () => {
|
||||
this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'art' }, bubbles: true }));
|
||||
});
|
||||
this.querySelector('[data-role=open-clues]').addEventListener('click', () => {
|
||||
this.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'clues' }, bubbles: true }));
|
||||
});
|
||||
}
|
||||
}
|
||||
customElements.define('director-rail', DirectorRail);
|
||||
|
||||
@@ -6,6 +6,7 @@ import './dice-tray.js';
|
||||
import './npc-tray.js';
|
||||
import './art-tray.js';
|
||||
import './prop-viewer.js';
|
||||
import './clue-net.js';
|
||||
|
||||
export class GmShell extends HTMLElement {
|
||||
constructor() {
|
||||
@@ -16,6 +17,7 @@ export class GmShell extends HTMLElement {
|
||||
this.clockState = initialClock();
|
||||
this.stamps = {};
|
||||
this.cast = [];
|
||||
this.revealedClues = [];
|
||||
this._tick = null;
|
||||
}
|
||||
|
||||
@@ -26,12 +28,14 @@ export class GmShell extends HTMLElement {
|
||||
<dice-tray hidden></dice-tray>
|
||||
<npc-tray hidden></npc-tray>
|
||||
<art-tray hidden></art-tray>
|
||||
<clue-net hidden></clue-net>
|
||||
</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));
|
||||
this.addEventListener('toggle-clue', (e) => this.onToggleClue(e));
|
||||
}
|
||||
|
||||
loadScenario(scenario) {
|
||||
@@ -41,6 +45,10 @@ export class GmShell extends HTMLElement {
|
||||
this.stamps = this.store.get('stamps', {});
|
||||
this.cast = this.store.get('cast', []);
|
||||
this.querySelector('dice-tray').systemId = scenario.meta.system;
|
||||
this.revealedClues = this.store.get('revealedClues', []);
|
||||
const net = this.querySelector('clue-net');
|
||||
net.clues = this.scenario.clues || [];
|
||||
net.revealed = this.revealedClues;
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
@@ -63,7 +71,7 @@ export class GmShell extends HTMLElement {
|
||||
|
||||
onOpenTool(e) {
|
||||
const tool = e.detail?.tool;
|
||||
const trays = { dice: 'dice-tray', npc: 'npc-tray', art: 'art-tray' };
|
||||
const trays = { dice: 'dice-tray', npc: 'npc-tray', art: 'art-tray', clues: 'clue-net' };
|
||||
if (!(tool in trays)) return;
|
||||
const target = this.querySelector(trays[tool]);
|
||||
const willShow = target.hidden;
|
||||
@@ -76,6 +84,15 @@ export class GmShell extends HTMLElement {
|
||||
this.store.set('cast', this.cast);
|
||||
}
|
||||
|
||||
onToggleClue(e) {
|
||||
const id = e.detail.id;
|
||||
this.revealedClues = this.revealedClues.includes(id)
|
||||
? this.revealedClues.filter((x) => x !== id)
|
||||
: [...this.revealedClues, id];
|
||||
this.store.set('revealedClues', this.revealedClues);
|
||||
this.querySelector('clue-net').revealed = this.revealedClues;
|
||||
}
|
||||
|
||||
onShowProp(e) {
|
||||
this.querySelector('prop-viewer').show(e.detail.src, e.detail.label);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// src/scenarios/example-with-clues.js
|
||||
// TEMPLATE / EXAMPLE — not a convention scenario. Demonstrates the clue model
|
||||
// for the safety-net. Copy this shape into a real scenario's `clues: []`.
|
||||
export default {
|
||||
meta: { id: 'example-clues', title: 'Example — Clue Net', system: 'year-zero', players: 4, playMinutes: 210, slot: 'Example' },
|
||||
timeline: [
|
||||
{ id: 'a1', act: 1, label: 'Opening', targetMin: 0, hardTrigger: true },
|
||||
{ id: 'a2', act: 2, label: 'The reveal', targetMin: 90, hardTrigger: true },
|
||||
{ id: 'a3', act: 3, label: 'Resolution', targetMin: 175, hardTrigger: true },
|
||||
],
|
||||
clues: [
|
||||
{ id: 'motive', label: 'The motive', essential: true, act: 'Act 1', fallback: 'The letter spells it out if missed' },
|
||||
{ id: 'means', label: 'The means', essential: true, act: 'Act 2', fallback: 'The coroner volunteers it at the morgue' },
|
||||
{ id: 'opportunity', label: 'The opportunity', essential: true, act: 'Act 2', fallback: 'The timetable is pinned to the wall' },
|
||||
{ id: 'flavour', label: 'A colour detail', essential: false, act: 'Act 1' },
|
||||
],
|
||||
cast: [],
|
||||
props: [],
|
||||
};
|
||||
@@ -61,3 +61,20 @@ prop-viewer[hidden] { display: none; }
|
||||
.params { display: flex; gap: 10px; flex-wrap: wrap; margin: 8px 0; }
|
||||
.param { font-size: 11px; color: #9fb0c2; display: flex; flex-direction: column; gap: 3px; }
|
||||
.param input { width: 80px; background: #161d27; border: 1px solid #2a3542; border-radius: 6px; padding: 6px 8px; color: #e7eef6; }
|
||||
|
||||
/* Slice 4 clue safety-net */
|
||||
.clue-net { position: absolute; top: 0; left: 0; right: 0; background: #0f141b; border-bottom: 1px solid #2b3644; padding: 14px; display: flex; flex-direction: column; gap: 10px; max-height: 100%; overflow-y: auto; }
|
||||
clue-net[hidden] { display: none; }
|
||||
.clue-banner { padding: 8px 12px; border-radius: 8px; font-weight: 700; font-size: 13px; }
|
||||
.clue-banner.ok { background: #17251c; color: #8ff0b6; }
|
||||
.clue-banner.warn { background: #2a2013; color: #ffd479; }
|
||||
.clue-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.clue-row { display: flex; gap: 10px; align-items: flex-start; padding: 8px; border: 1px solid #263041; border-radius: 8px; }
|
||||
.clue-row.revealed { opacity: .6; }
|
||||
.clue-toggle { width: 34px; height: 34px; border-radius: 8px; border: 1px solid #2a3542; background: #161d27; color: #cfe0f0; font-size: 16px; flex: 0 0 auto; }
|
||||
.clue-body { flex: 1; }
|
||||
.clue-label { font-size: 13px; color: #e7eef6; }
|
||||
.clue-ess { font-size: 9px; text-transform: uppercase; letter-spacing: .08em; color: #ffd479; border: 1px solid #4a3a1f; border-radius: 10px; padding: 1px 6px; }
|
||||
.clue-act { font-size: 10px; color: #7f8ea0; }
|
||||
.clue-fallback { margin-top: 4px; font-size: 12px; color: #ff9c9c; }
|
||||
.clue-empty { color: #6b7a8d; font-size: 12px; }
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import '../../src/components/gm-shell.js';
|
||||
import example from '../../src/scenarios/example-with-clues.js';
|
||||
|
||||
describe('<gm-shell> clue safety-net', () => {
|
||||
let el;
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = '';
|
||||
el = document.createElement('gm-shell');
|
||||
el.now = () => 0;
|
||||
document.body.appendChild(el);
|
||||
el.loadScenario(example);
|
||||
});
|
||||
|
||||
it('mounts a hidden clue-net fed from the scenario clues', () => {
|
||||
const net = el.querySelector('clue-net');
|
||||
expect(net).not.toBe(null);
|
||||
expect(net.hidden).toBe(true);
|
||||
expect(net.clues.length).toBe(example.clues.length);
|
||||
});
|
||||
|
||||
it('open-tool clues reveals the clue-net', () => {
|
||||
el.dispatchEvent(new CustomEvent('open-tool', { detail: { tool: 'clues' }, bubbles: true }));
|
||||
expect(el.querySelector('clue-net').hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('toggle-clue records the id, persists it, and updates the net', () => {
|
||||
const id = example.clues[0].id;
|
||||
el.dispatchEvent(new CustomEvent('toggle-clue', { detail: { id }, bubbles: true }));
|
||||
expect(el.revealedClues).toContain(id);
|
||||
const persisted = JSON.parse(localStorage.getItem('gmd.' + example.meta.id + '.revealedClues'));
|
||||
expect(persisted).toContain(id);
|
||||
expect(el.querySelector('clue-net').revealed).toContain(id);
|
||||
// toggling again removes it
|
||||
el.dispatchEvent(new CustomEvent('toggle-clue', { detail: { id }, bubbles: true }));
|
||||
expect(el.revealedClues).not.toContain(id);
|
||||
});
|
||||
|
||||
it('the rail exposes a clues chip that emits 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-clues]').click();
|
||||
expect(tool).toBe('clues');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user