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
This commit is contained in:
2026-06-04 00:02:51 +01:00
parent 27bc43cea6
commit 25250dc98a
5 changed files with 190 additions and 35 deletions
+14
View File
@@ -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); }
+1 -1
View File
@@ -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"
+2 -1
View File
@@ -13,7 +13,8 @@
"scl": 10,
"credits": 500,
"isPlayerCharacter": false,
"ladAccount": false
"ladAccount": false,
"showingOff": 0
},
"race": [],
"training": [],
+8
View File
@@ -891,6 +891,14 @@
<span class="fin-credit-value credits-readonly-wrap"><input type="number" readonly value="{{system.details.credits}}" class="credits-readonly" title="Edit credits in the sheet header" /> c</span>
{{#if system.finances.debt}}<span class="fin-debt-badge">⚠ DEBT {{system.finances.debt}}c</span>{{/if}}
</div>
<div class="finances-showing-off-hero">
<span class="fin-so-label">✨ SHOWING OFF</span>
<div class="fin-so-value-wrap">
<input type="number" name="system.details.showingOff" value="{{system.details.showingOff}}" class="fin-so-value" min="0" title="Accumulated showing off points" />
<span class="fin-so-unit">pts</span>
</div>
{{#if (gte system.details.showingOff 20)}}<span class="fin-so-milestone">⚠ 20 PT MILESTONE — GM TO ACT</span>{{/if}}
</div>
<div class="finances-grid">
<div class="fin-panel income-panel">
+165 -33
View File
@@ -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
</div>
</div>
<div class="form-group" style="margin:10px 0 4px;">
<button type="button" id="showing-off-btn" style="
width:100%; padding:10px 16px; font-size:15px; font-weight:bold;
text-transform:uppercase; letter-spacing:2px; cursor:pointer;
background:linear-gradient(135deg,rgba(255,215,0,0.22),rgba(255,120,0,0.18));
border:2px solid #ffaa00; border-radius:6px; color:#ffdd44;
text-shadow:0 0 8px rgba(255,200,0,0.6); transition:all 0.2s;">
✨ SHOWING OFF ✨
</button>
<div id="so-display" style="text-align:center;font-size:11px;color:#cc9900;min-height:16px;margin-top:3px;"></div>
</div>
${showShotSelector ? `
<div class="form-group" style="background:rgba(204,17,17,0.08);border:1px solid rgba(204,17,17,0.25);border-radius:4px;padding:6px 8px;">
<label style="color:#cc6655;font-weight:bold;">⚡ Shots Fired:</label>
@@ -139,25 +156,25 @@ class YZEDiceDialog extends Dialog {
icon: '<i class="fas fa-dice-d6"></i>',
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: <strong>${showingOff}</strong> × (−${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: `
<div style="
text-align:center; padding:14px 12px;
background:linear-gradient(135deg,rgba(255,215,0,0.2),rgba(255,100,0,0.15));
border:2px solid #ffcc00; border-radius:8px;">
<div style="font-size:30px;font-weight:bold;color:#ffdd22;
text-shadow:0 0 12px rgba(255,200,0,0.7);letter-spacing:3px;">
${starBar} SHOWING OFF${excl} ${starBar}
</div>
<div style="font-size:14px;color:#ff9900;margin-top:6px;">
${actorName} is playing to the cameras!
</div>
<div style="font-size:12px;color:#cc8800;margin-top:4px;">
This roll: −${showingOff} dice &nbsp;|&nbsp; Career total: <strong>${newTally}</strong> pt${newTally !== 1 ? 's' : ''}
</div>
</div>`
});
// 20-point milestone — broadcast publicly
if (actorId && newTally === 20) {
await ChatMessage.create({
speaker: { alias: '⚠ Zero Engine' },
content: `
<div style="
text-align:center; padding:14px 12px;
background:linear-gradient(135deg,rgba(255,30,0,0.25),rgba(180,0,0,0.2));
border:2px solid #ff3300; border-radius:8px;">
<div style="font-size:22px;font-weight:bold;color:#ff4422;letter-spacing:2px;">
⚠ 20 POINTS OF SHOWING OFF ⚠
</div>
<div style="font-size:14px;color:#ff6644;margin-top:6px;">
${actorName} has reached <strong>20 points</strong> of showing off.<br>
GM — time to make them pay for it.
</div>
</div>`
});
}
})();
});
}
}, {
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() {
<div style="font-size:11px;line-height:1.6;">
<span style="color:${hpColour}">❤ HP ${hp}/${hpMax}</span> &nbsp;
<span style="color:${resColour}">🧠 Resolve ${res}/${resMax}</span> &nbsp;
<span style="color:${stressColour}">⚡ Stress ${stress}</span>
<span style="color:${stressColour}">⚡ Stress ${stress}</span> &nbsp;
<span style="color:${soColour}">✨ Showing Off ${soTally}</span>
</div>
<div style="font-size:11px;margin-top:2px;">
${activeConds || '<span style="color:#44cc66;font-size:10px;">No active conditions</span>'}