// The turn screen, in two phases. // // Choosing: the scenario and its options, with locked ones shown greyed and // labelled with what they are waiting for — a player should be able to see the // door they cannot open yet, because that is most of what makes the numbers // mean anything. // // Outcome: what happened, what moved, and what the day cost regardless of the // choice. The feedback widget lives here rather than in the choosing phase, // because this is the moment a player has an opinion. import { h } from '../dom.js'; import { statPanel, characterCard, changeList, statusBar } from '../components.js'; import { requirementText, turnHeading, upcomingUpkeep } from '../format.js'; import { presentPeople } from '../../engine/cast.js'; import { fillText } from '../../engine/text.js'; export function turnScreen(pack, state, schema, view, actions) { const heading = turnHeading(pack, state); return h('div', { class: 'screen screen--turn' }, statusBar(pack, state), h('header', { class: 'hud' }, h('div', { class: 'hud__where' }, h('p', { class: 'hud__stage' }, pack.stages?.[state.stage]?.title ?? state.stage), h('h1', { class: 'hud__day' }, heading.title), heading.detail && h('p', { class: 'hud__detail' }, heading.detail), ), statPanel(pack, state, schema), ), diary(pack, state), h('div', { class: 'layout' }, h('main', { class: 'scene' }, view.phase === 'outcome' ? outcomeView(pack, state, view, actions) : choosingView(pack, state, view, actions), ), h('aside', { class: 'people' }, h('h2', { class: 'people__heading' }, pack.peopleHeading ?? 'The people'), presentPeople(pack, state).map((person) => characterCard(pack, state, schema, person)), ), ), // Order here is tab order. "Wrap up my career" used to sit first, directly // after the last option — so a player tabbing to reach an option, going one // past it, and pressing Enter got the retrospective instead of a turn. That // happened in a real session at day 31 and read as the game ending. It goes // last now, behind buttons that do nothing frightening if pressed by // accident, and Import save is next to Export save where it belongs. h('footer', { class: 'controls' }, h('button', { class: 'button button--quiet', onClick: actions.onExportSave }, 'Export save'), h('button', { class: 'button button--quiet', onClick: actions.onImportSave }, 'Import save'), h('button', { class: 'button button--quiet', onClick: actions.onExportFeedback }, 'Export feedback log'), h('button', { class: 'button button--quiet', onClick: actions.onPacks }, 'Change setting'), h('button', { class: 'button button--quiet', onClick: actions.onNewGame }, 'New game'), h('button', { class: 'button', onClick: actions.onRetrospective }, 'Wrap up my career'), ), ); } /** * What is coming and when. A player asked what the daily drain was for; the * answer is that most of it is not daily, and this says so before the money * moves rather than after. */ function diary(pack, state) { const upcoming = upcomingUpkeep(pack, state); if (!upcoming.length) return null; const unit = pack.turnUnit ?? 'turn'; return h('p', { class: 'diary' }, upcoming.map((entry, index) => h('span', { class: `diary__entry diary__entry--${entry.incoming ? 'in' : 'out'}`, }, index > 0 ? ' · ' : '', entry.label, entry.turnsAway === 0 ? ' today' : entry.turnsAway === 1 ? ` tomorrow` : ` in ${entry.turnsAway} ${unit}s`, )), ); } function choosingView(pack, state, view, actions) { const { event, options } = view.turn; const text = (t) => fillText(pack, state, t, view.turn.bindings); const visible = options.filter((o) => !o.hidden); // The first option a player can actually take gets the focus, so Enter plays // it and Tab walks to the others. const firstAvailable = visible.find((o) => o.available)?.option.id; return h('section', { class: 'scenario' }, h('p', { class: 'scenario__text' }, text(event.description)), h('ul', { class: 'options' }, visible.map(({ option, available, failed, odds }) => h('li', { class: 'options__item' }, h('button', { class: `option${available ? '' : ' option--locked'}`, disabled: !available, dataset: option.id === firstAvailable ? { autofocus: 'true' } : {}, onClick: available ? () => actions.onChoose(option.id) : null, }, h('span', { class: 'option__label' }, text(option.label)), // Odds are shown in words, never as a number: "a long shot", "likely". available && odds && h('span', { class: 'option__odds' }, odds.words), !available && h('span', { class: 'option__requirement' }, requirementText(pack, failed, state)), ), )), ), ); } function outcomeView(pack, state, view, actions) { const { record, option } = view; // The state has already moved on to the next event, so the names in this // outcome come from the bindings the turn was played with, not the new ones. const text = (t) => fillText(pack, state, t, record.bindings ?? {}); const branch = record.check ? option[record.check.outcome] : null; const result = branch ? branch.result : option.result; const said = pack.checks?.said ?? { success: 'It came off.', failure: 'It did not come off.' }; return h('section', { class: 'outcome' }, h('p', { class: 'outcome__chose' }, record.optionLabel ?? text(option.label)), record.check && h('p', { class: `outcome__check outcome__check--${record.check.outcome}` }, `${capitalise(record.check.odds)} — ${said[record.check.outcome]}`), result && h('p', { class: 'outcome__text' }, text(result)), h('div', { class: 'outcome__ledger' }, h('h3', { class: 'outcome__heading' }, 'What that changed'), changeList(pack, record.changes, { state }), record.upkeep.length > 0 && h('div', { class: 'outcome__upkeep' }, h('h3', { class: 'outcome__heading' }, 'And then the day happened'), changeList(pack, record.upkeep, { state }), ), ), feedbackWidget(view, actions), h('button', { class: 'button button--primary button--next', dataset: { autofocus: 'true' }, onClick: actions.onNext, }, `Next ${pack.turnUnit ?? 'turn'} →`), ); } // Entirely optional: ignoring it does nothing at all, which is the point. function feedbackWidget(view, actions) { const { feedback } = view; const ratingButton = (value, label) => h('button', { class: `rating${feedback.rating === value ? ' rating--on' : ''}`, type: 'button', 'aria-pressed': String(feedback.rating === value), onClick: () => actions.onRate(value), }, label); return h('section', { class: 'feedback' }, h('div', { class: 'feedback__row' }, h('span', { class: 'feedback__prompt' }, 'How was that?'), h('div', { class: 'feedback__ratings' }, ratingButton('up', '👍'), ratingButton('neutral', '😐'), ratingButton('down', '👎'), ), h('button', { class: 'feedback__toggle', type: 'button', onClick: actions.onToggleFeedbackDetail, }, view.feedbackOpen ? 'Fewer options' : 'Say more'), ), view.feedbackOpen && h('div', { class: 'feedback__detail' }, h('label', { class: 'field' }, h('span', { class: 'field__label' }, 'Anything you want to tell the designer?'), h('textarea', { class: 'field__input', rows: 2, value: feedback.comment ?? '', onInput: (e) => actions.onComment(e.target.value), }), ), h('label', { class: 'field' }, h('span', { class: 'field__label' }, "Something you wanted to do that wasn't offered?"), h('textarea', { class: 'field__input', rows: 2, value: feedback.desiredAction ?? '', onInput: (e) => actions.onDesiredAction(e.target.value), }), ), ), feedback.saved && h('p', { class: 'feedback__saved' }, 'Noted — thank you.'), ); } const capitalise = (words) => words.charAt(0).toUpperCase() + words.slice(1);