From 25250dc98a794fa72d09726e7de29ad70cc59007 Mon Sep 17 00:00:00 2001 From: Tim Evans Date: Thu, 4 Jun 2026 00:02:51 +0100 Subject: [PATCH] feat: Showing Off mechanic (v1.6.2) Every roll dialog now has a gold SHOWING OFF button: - Each click posts a big gold fanfare chat message + plays a WebAudio arpeggio - Reduces the dice pool by 1 per click (floor: 1d6) - Total dice display turns gold and shows the penalty - Immediately increments actor system.details.showingOff tally - At exactly 20 pts: public milestone broadcast with GM alert - Finance tab: gold SHOWING OFF hero panel shows career total (editable) - Hourly PC Status Report now includes Showing Off tally (yellow at >0, orange at 10+, red at 20+) - template.json: showingOff: 0 added to character.details - _playShowingOffFanfare(): ascending C-major arpeggio via WebAudio API --- styles/zero-engine.css | 14 ++ system.json | 2 +- template.json | 3 +- templates/actor/character-sheet.hbs | 8 ++ zero-engine.mjs | 198 +++++++++++++++++++++++----- 5 files changed, 190 insertions(+), 35 deletions(-) diff --git a/styles/zero-engine.css b/styles/zero-engine.css index ed15c6e..23bfca5 100644 --- a/styles/zero-engine.css +++ b/styles/zero-engine.css @@ -3926,6 +3926,20 @@ .zero-engine .fin-credit-value { font-size: 28px; font-weight: bold; color: #00d4ff; } .zero-engine .fin-credit-value input { background: transparent; border: none; color: #00d4ff; font-size: 26px; font-weight: bold; width: 100px; } .zero-engine .fin-debt-badge { background: rgba(220,40,40,0.2); border: 1px solid #cc2222; border-radius: 4px; padding: 3px 8px; color: #ff4444; font-size: 12px; font-weight: bold; margin-left: auto; } +/* ── Showing Off hero panel ─────────────────────────────── */ +.zero-engine .finances-showing-off-hero { + display: flex; align-items: center; gap: 16px; + background: rgba(255,200,0,0.07); border: 2px solid rgba(255,190,0,0.35); + border-radius: 6px; padding: 10px 18px; margin-bottom: 14px; +} +.zero-engine .fin-so-label { font-size: 11px; color: #cc9900; text-transform: uppercase; letter-spacing: 1px; } +.zero-engine .fin-so-value-wrap { display: flex; align-items: baseline; gap: 4px; } +.zero-engine .fin-so-value { background: transparent; border: none; color: #ffdd44; font-size: 26px; font-weight: bold; width: 60px; } +.zero-engine .fin-so-unit { font-size: 13px; color: #aa8800; } +.zero-engine .fin-so-milestone { + margin-left: auto; background: rgba(220,40,0,0.2); border: 1px solid #cc2200; + border-radius: 4px; padding: 3px 8px; color: #ff4422; font-size: 11px; font-weight: bold; +} .zero-engine .finances-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; } .zero-engine .fin-panel { background: rgba(0,0,0,0.25); border: 1px solid rgba(255,102,0,0.2); border-radius: 6px; padding: 10px; } .zero-engine .fin-panel-title { font-size: 12px; font-weight: bold; text-transform: uppercase; letter-spacing: 1px; padding-bottom: 6px; margin-bottom: 8px; border-bottom: 1px solid rgba(255,255,255,0.1); } diff --git a/system.json b/system.json index 74014ab..4b3ca2e 100644 --- a/system.json +++ b/system.json @@ -2,7 +2,7 @@ "id": "zero-engine", "title": "Zero Engine (SLA Industries)", "description": "A basic game system for SLA Industries using the Zero Engine rules.", - "version": "1.6.1", + "version": "1.6.2", "compatibility": { "minimum": 13, "verified": "14.359" diff --git a/template.json b/template.json index 79c34f9..c5635c4 100644 --- a/template.json +++ b/template.json @@ -13,7 +13,8 @@ "scl": 10, "credits": 500, "isPlayerCharacter": false, - "ladAccount": false + "ladAccount": false, + "showingOff": 0 }, "race": [], "training": [], diff --git a/templates/actor/character-sheet.hbs b/templates/actor/character-sheet.hbs index 957f2b1..26b486e 100644 --- a/templates/actor/character-sheet.hbs +++ b/templates/actor/character-sheet.hbs @@ -891,6 +891,14 @@ c {{#if system.finances.debt}}⚠ DEBT {{system.finances.debt}}c{{/if}} +
+ ✨ SHOWING OFF +
+ + pts +
+ {{#if (gte system.details.showingOff 20)}}⚠ 20 PT MILESTONE — GM TO ACT{{/if}} +
diff --git a/zero-engine.mjs b/zero-engine.mjs index 6d2de1e..5ab2823 100644 --- a/zero-engine.mjs +++ b/zero-engine.mjs @@ -44,6 +44,10 @@ class YZEDiceDialog extends Dialog { static async show(baseDice, label, formula = '', options = {}) { return new Promise((resolve) => { + + // ── Showing Off counter (shared between render + roll callback) ────────── + let showingOff = 0; + const normalizedFireModes = Array.isArray(options.fireModes) ? options.fireModes : (typeof options.fireModes === "string" @@ -56,9 +60,9 @@ class YZEDiceDialog extends Dialog { const modeBonusMap = options.modeBonusMap || {}; const ammoBonusMap = options.ammoBonusMap || {}; const showAttackMods = !!options.showAttackMods; - // Multi-shot options (passed from _onWeaponRoll) const shotOptions = Array.isArray(options.shotOptions) ? options.shotOptions : []; const showShotSelector = shotOptions.length > 1; + const dialog = new Dialog({ title: `Roll ${label}`, content: ` @@ -91,6 +95,19 @@ class YZEDiceDialog extends Dialog { ${baseDice}d6
+ +
+ +
+
+ ${showShotSelector ? `
@@ -139,25 +156,25 @@ class YZEDiceDialog extends Dialog { icon: '', label: "Roll", callback: (html) => { - const modifier = parseInt(html.find('[name="modifier"]').val()) || 0; - const fireMode = html.find('[name="fireMode"]').val() || defaultFireMode; - const ammoType = html.find('[name="ammoType"]').val() || defaultAmmoType; - const rangeBand = html.find('[name="rangeBand"]').val() || "normal"; - const useScope = html.find('[name="useScope"]').is(':checked'); - const useAI = html.find('[name="useAI"]').is(':checked'); - const modeBonus = parseInt(modeBonusMap[fireMode]) || 0; - const ammoBonus = parseInt(ammoBonusMap[ammoType]) || 0; + const modifier = parseInt(html.find('[name="modifier"]').val()) || 0; + const fireMode = html.find('[name="fireMode"]').val() || defaultFireMode; + const ammoType = html.find('[name="ammoType"]').val() || defaultAmmoType; + const rangeBand = html.find('[name="rangeBand"]').val() || "normal"; + const useScope = html.find('[name="useScope"]').is(':checked'); + const useAI = html.find('[name="useAI"]').is(':checked'); + const modeBonus = parseInt(modeBonusMap[fireMode]) || 0; + const ammoBonus = parseInt(ammoBonusMap[ammoType]) || 0; const rangeBonus = rangeBand === "point_blank" ? 1 : (rangeBand === "extreme" ? -2 : 0); const scopeBonus = (useScope && (rangeBand === "long" || rangeBand === "extreme")) ? 2 : 0; - const aiBonus = useAI ? 2 : 0; - // Multi-shot bonus from shot selector - const shotIdx = parseInt(html.find('[name="shotCount"]').val()) || 0; - const chosenShot = shotOptions[shotIdx] || shotOptions[0] || { shots: 1, diceBonus: 0 }; + const aiBonus = useAI ? 2 : 0; + const shotIdx = parseInt(html.find('[name="shotCount"]').val()) || 0; + const chosenShot = shotOptions[shotIdx] || shotOptions[0] || { shots: 1, diceBonus: 0 }; const shotDiceBonus = chosenShot.diceBonus || 0; - const shotsChosen = chosenShot.shots || 1; - const totalDice = Math.max(0, baseDice + modifier + modeBonus + ammoBonus + rangeBonus + scopeBonus + aiBonus + shotDiceBonus); + const shotsChosen = chosenShot.shots || 1; + // Showing off reduces pool — minimum 1d6 + const totalDice = Math.max(1, baseDice + modifier + modeBonus + ammoBonus + rangeBonus + scopeBonus + aiBonus + shotDiceBonus - showingOff); if (options.returnDetails) { - resolve({ totalDice, fireMode, ammoType, modifier, rangeBand, useScope, useAI, rangeBonus, scopeBonus, aiBonus, shotsChosen, shotDiceBonus }); + resolve({ totalDice, fireMode, ammoType, modifier, rangeBand, useScope, useAI, rangeBonus, scopeBonus, aiBonus, shotsChosen, shotDiceBonus, showingOff }); } else { resolve(totalDice); } @@ -172,23 +189,27 @@ class YZEDiceDialog extends Dialog { default: "roll", render: (html) => { const modifierInput = html.find('[name="modifier"]'); - const totalDisplay = html.find('#total-dice'); + const totalDisplay = html.find('#total-dice'); + const soDisplay = html.find('#so-display'); - // Update total when input changes const updateTotal = () => { - const modifier = parseInt(modifierInput.val()) || 0; - const fireMode = html.find('[name="fireMode"]').val() || defaultFireMode; - const ammoType = html.find('[name="ammoType"]').val() || defaultAmmoType; - const rangeBand = html.find('[name="rangeBand"]').val() || "normal"; - const useScope = html.find('[name="useScope"]').is(':checked'); - const useAI = html.find('[name="useAI"]').is(':checked'); - const modeBonus = parseInt(modeBonusMap[fireMode]) || 0; - const ammoBonus = parseInt(ammoBonusMap[ammoType]) || 0; + const modifier = parseInt(modifierInput.val()) || 0; + const fireMode = html.find('[name="fireMode"]').val() || defaultFireMode; + const ammoType = html.find('[name="ammoType"]').val() || defaultAmmoType; + const rangeBand = html.find('[name="rangeBand"]').val() || "normal"; + const useScope = html.find('[name="useScope"]').is(':checked'); + const useAI = html.find('[name="useAI"]').is(':checked'); + const modeBonus = parseInt(modeBonusMap[fireMode]) || 0; + const ammoBonus = parseInt(ammoBonusMap[ammoType]) || 0; const rangeBonus = rangeBand === "point_blank" ? 1 : (rangeBand === "extreme" ? -2 : 0); const scopeBonus = (useScope && (rangeBand === "long" || rangeBand === "extreme")) ? 2 : 0; - const aiBonus = useAI ? 2 : 0; - const total = Math.max(0, baseDice + modifier + modeBonus + ammoBonus + rangeBonus + scopeBonus + aiBonus); + const aiBonus = useAI ? 2 : 0; + const total = Math.max(1, baseDice + modifier + modeBonus + ammoBonus + rangeBonus + scopeBonus + aiBonus - showingOff); totalDisplay.text(`${total}d6`); + if (showingOff > 0) { + totalDisplay.css('color', '#ffaa00'); + soDisplay.html(`✨ Showing Off: ${showingOff} × (−${showingOff} dice)`); + } }; modifierInput.on('input', updateTotal); @@ -198,16 +219,94 @@ class YZEDiceDialog extends Dialog { html.find('[name="useScope"]').on('change', updateTotal); html.find('[name="useAI"]').on('change', updateTotal); - // Handle modifier buttons html.find('.modify-dice').on('click', (event) => { const modifier = parseInt(event.currentTarget.dataset.modifier); const currentValue = parseInt(modifierInput.val()) || 0; modifierInput.val(currentValue + modifier); updateTotal(); }); + + // ── SHOWING OFF button ───────────────────────────────────────────── + html.find('#showing-off-btn').on('click', () => { + // Check current pool would stay >= 1 + const modifier = parseInt(modifierInput.val()) || 0; + const rangeBonus = (() => { + const rb = html.find('[name="rangeBand"]').val() || "normal"; + return rb === "point_blank" ? 1 : (rb === "extreme" ? -2 : 0); + })(); + const currentTotal = baseDice + modifier + rangeBonus - showingOff; + if (currentTotal <= 1) { + ui.notifications.warn("Can't show off any more — pool is already at 1d6 minimum!"); + return; + } + + showingOff++; + updateTotal(); + _playShowingOffFanfare(); + + // Async side-effects — update actor tally + chat message + (async () => { + const actorId = options.actorId; + const actorName = options.actorName ?? 'Someone'; + let newTally = showingOff; // fallback if no actor + + if (actorId) { + const actor = game.actors.get(actorId); + if (actor) { + const prev = Number(actor.system.details?.showingOff ?? 0); + newTally = prev + 1; + await actor.update({ 'system.details.showingOff': newTally }); + } + } + + const exclamations = ['!', ' AGAIN!', ' EVEN MORE!', ' OUTRAGEOUSLY!', ' SPECTACULARLY!', ' BRAZENLY!']; + const excl = exclamations[Math.min(showingOff - 1, exclamations.length - 1)]; + const starBar = '✨'.repeat(Math.min(showingOff, 5)); + + await ChatMessage.create({ + speaker: { alias: actorName }, + content: ` +
+
+ ${starBar} SHOWING OFF${excl} ${starBar} +
+
+ ${actorName} is playing to the cameras! +
+
+ This roll: −${showingOff} dice  |  Career total: ${newTally} pt${newTally !== 1 ? 's' : ''} +
+
` + }); + + // 20-point milestone — broadcast publicly + if (actorId && newTally === 20) { + await ChatMessage.create({ + speaker: { alias: '⚠ Zero Engine' }, + content: ` +
+
+ ⚠ 20 POINTS OF SHOWING OFF ⚠ +
+
+ ${actorName} has reached 20 points of showing off.
+ GM — time to make them pay for it. +
+
` + }); + } + })(); + }); } }, { - width: 400, + width: 420, classes: ["zero-engine", "dialog"] }); @@ -1617,7 +1716,9 @@ class ZeroEngineActorSheet extends foundry.appv1.sheets.ActorSheet { modeBonusMap: { single: 0, burst: 1, auto: 2 }, ammoBonusMap: { standard: 0, ap: 1, he: 0 }, showAttackMods: true, - shotOptions: dialogShotOptions // ← multi-shot selector + shotOptions: dialogShotOptions, + actorId: this.actor?.id, + actorName: this.actor?.name } ); @@ -2423,7 +2524,10 @@ class ZeroEngineActorSheet extends foundry.appv1.sheets.ActorSheet { let modifiedDice = baseDice; if (!rollOptions.skipDialog) { // Show dialog to modify dice pool - modifiedDice = await YZEDiceDialog.show(baseDice, label, formula); + modifiedDice = await YZEDiceDialog.show(baseDice, label, formula, { + actorId: this.actor?.id, + actorName: this.actor?.name + }); if (modifiedDice === null) return; // Dialog was cancelled } else if (typeof rollOptions.preModifiedDice === "number") { modifiedDice = rollOptions.preModifiedDice; @@ -7725,6 +7829,31 @@ const SLA_CONDITIONS = [ // ─── CONDITION MODIFIER HELPER ──────────────────────────────────────────────── +/** + * Play a short "ta-da" fanfare using the Web Audio API — no external files needed. + */ +function _playShowingOffFanfare() { + try { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + // Ascending major arpeggio: C5 E5 G5 C6 + const notes = [523.25, 659.25, 783.99, 1046.5]; + notes.forEach((freq, i) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.type = 'sine'; + osc.frequency.value = freq; + const t = ctx.currentTime + i * 0.13; + gain.gain.setValueAtTime(0, t); + gain.gain.linearRampToValueAtTime(0.35, t + 0.04); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.28); + osc.start(t); + osc.stop(t + 0.30); + }); + } catch(_) { /* WebAudio unavailable — silent fallback */ } +} + /** * Post a whispered GM report of every PC's current conditions, HP, Resolve, and Stress. * Called hourly from the ready hook and on demand from the STATUS button on any sheet. @@ -7753,9 +7882,11 @@ async function _broadcastPCConditions() { const resMax = Number(pc.system.derivedStats?.resolve?.max ?? 0); const stress = Number(pc.system.derivedStats?.stress ?? 0); + const soTally = Number(pc.system.details?.showingOff ?? 0); const hpColour = hp <= 0 ? '#ff2222' : hp <= hpMax * 0.3 ? '#ff8800' : '#44cc66'; const resColour = res <= 0 ? '#ff2222' : res <= resMax * 0.3 ? '#ff8800' : '#44ccff'; const stressColour = stress >= 8 ? '#ff2222' : stress >= 5 ? '#ff8800' : '#aaaaaa'; + const soColour = soTally >= 20 ? '#ff2222' : soTally >= 10 ? '#ff8800' : soTally > 0 ? '#ffcc00' : '#555'; const activeConds = [...(pc.statuses ?? new Set())] .filter(id => condLabels[id]) @@ -7768,7 +7899,8 @@ async function _broadcastPCConditions() {
❤ HP ${hp}/${hpMax}   🧠 Resolve ${res}/${resMax}   - ⚡ Stress ${stress} + ⚡ Stress ${stress}   + ✨ Showing Off ${soTally}
${activeConds || 'No active conditions'}