Support live attribute restoration mechanics
This commit is contained in:
@@ -12,7 +12,8 @@
|
||||
subjectLabel = 'TALENTS',
|
||||
searchLabel = `FILTER AVAILABLE ${subjectLabel}`,
|
||||
searchPlaceholder = `Search ${subjectLabel.toLocaleLowerCase()}…`,
|
||||
emptySelectionLabel = `NO ${subjectLabel} ASSIGNED`
|
||||
emptySelectionLabel = `NO ${subjectLabel} ASSIGNED`,
|
||||
onvaluechange
|
||||
}: {
|
||||
items: Item[];
|
||||
selected?: string[];
|
||||
@@ -26,6 +27,7 @@
|
||||
searchLabel?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptySelectionLabel?: string;
|
||||
onvaluechange?: (value: string) => void;
|
||||
} = $props();
|
||||
let query = $state('');
|
||||
let open = $state(false);
|
||||
@@ -42,6 +44,7 @@
|
||||
const toggle = (id: string) => {
|
||||
if (mode === 'single') {
|
||||
value = value === id ? '' : id;
|
||||
onvaluechange?.(value);
|
||||
if (compact) {
|
||||
open = false;
|
||||
query = '';
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
let recipeWindow = $state(3);
|
||||
let recipeDistance = $state(10);
|
||||
let replaceConfirmation = $state(false);
|
||||
let valueModelOverrides = $state<Record<string, OutcomeValueModel>>({});
|
||||
|
||||
const readinessOptions = [
|
||||
{ value: 'ready', label: 'READY' },
|
||||
@@ -173,7 +174,7 @@
|
||||
kind: 'attribute_modifier',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
perStack: true,
|
||||
perStack: false,
|
||||
descriptionSlot: index + 1,
|
||||
attributeId: '',
|
||||
variants: Object.fromEntries(valueColumns.map((column) => [
|
||||
@@ -233,6 +234,7 @@
|
||||
type OutcomeValueModel =
|
||||
| 'static'
|
||||
| 'event'
|
||||
| 'attribute'
|
||||
| 'reducer'
|
||||
| 'scenario'
|
||||
| 'protocol_upgrades'
|
||||
@@ -241,6 +243,7 @@
|
||||
const valueModelOptions = $derived([
|
||||
{ value: 'static', label: 'STATIC' },
|
||||
{ value: 'event', label: 'EVENT FIELD' },
|
||||
{ value: 'attribute', label: 'ATTRIBUTE ÷ 100' },
|
||||
{ value: 'reducer', label: 'REDUCER' },
|
||||
{ value: 'scenario', label: 'SCENARIO' },
|
||||
...(allowProtocolUpgradeScaling
|
||||
@@ -283,7 +286,20 @@
|
||||
};
|
||||
}
|
||||
|
||||
function attributePercentExpression(value: number, attributeId: string): NumericExpression {
|
||||
return {
|
||||
op: 'multiply',
|
||||
left: value,
|
||||
right: {
|
||||
op: 'divide',
|
||||
left: { ref: { source: 'attribute', attributeId } },
|
||||
right: 100
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function outcomeValueModel(outcome: ConditionalMechanicOutcomeDraft): OutcomeValueModel {
|
||||
if (valueModelOverrides[outcome.key]) return valueModelOverrides[outcome.key];
|
||||
const expression = Object.values(outcome.variants)[0]?.expression;
|
||||
if (expression === undefined) return 'static';
|
||||
if (
|
||||
@@ -298,6 +314,14 @@
|
||||
if (expression.right.ref.source === 'scenario') return 'scenario';
|
||||
if (expression.right.ref.source === 'reducer') return 'reducer';
|
||||
}
|
||||
if (
|
||||
'op' in expression.right
|
||||
&& expression.right.op === 'divide'
|
||||
&& typeof expression.right.left === 'object'
|
||||
&& 'ref' in expression.right.left
|
||||
&& expression.right.left.ref.source === 'attribute'
|
||||
&& expression.right.right === 100
|
||||
) return 'attribute';
|
||||
if (
|
||||
'op' in expression.right
|
||||
&& expression.right.op === 'add'
|
||||
@@ -330,12 +354,32 @@
|
||||
return source === 'event' ? 'values.enemyCount' : rule.scenarioInputs[0]?.key ?? '';
|
||||
}
|
||||
|
||||
function outcomeAttributeReference(outcome: ConditionalMechanicOutcomeDraft) {
|
||||
const expression = Object.values(outcome.variants)[0]?.expression;
|
||||
if (
|
||||
expression
|
||||
&& typeof expression === 'object'
|
||||
&& 'op' in expression
|
||||
&& expression.op === 'multiply'
|
||||
&& typeof expression.right === 'object'
|
||||
&& 'op' in expression.right
|
||||
&& expression.right.op === 'divide'
|
||||
&& typeof expression.right.left === 'object'
|
||||
&& 'ref' in expression.right.left
|
||||
&& expression.right.left.ref.source === 'attribute'
|
||||
) return expression.right.left.ref.attributeId;
|
||||
return '';
|
||||
}
|
||||
|
||||
function expressionForModel(
|
||||
model: OutcomeValueModel,
|
||||
value: number,
|
||||
reference = ''
|
||||
): NumericExpression | undefined {
|
||||
if (model === 'event') return referenceExpression('event', reference || 'values.enemyCount', value);
|
||||
if (model === 'attribute') return reference
|
||||
? attributePercentExpression(value, reference)
|
||||
: undefined;
|
||||
if (model === 'scenario') return reference
|
||||
? referenceExpression('scenario', reference, value)
|
||||
: undefined;
|
||||
@@ -353,6 +397,10 @@
|
||||
reference = ''
|
||||
) {
|
||||
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
|
||||
const nextOverrides = { ...valueModelOverrides };
|
||||
if (model === 'attribute' && !reference) nextOverrides[outcome.key] = 'attribute';
|
||||
else delete nextOverrides[outcome.key];
|
||||
valueModelOverrides = nextOverrides;
|
||||
const variants = Object.fromEntries(Object.entries(outcome.variants).map(([key, variant]) => {
|
||||
const expression = expressionForModel(model, Number(variant.value), reference);
|
||||
return [key, expression === undefined ? { value: Number(variant.value) } : {
|
||||
@@ -383,6 +431,17 @@
|
||||
expression as { right: { ref: { key: string } } }
|
||||
).right.ref.key, value);
|
||||
}
|
||||
if (model === 'attribute') {
|
||||
return attributePercentExpression(value, outcomeAttributeReference({
|
||||
key: 'temporary',
|
||||
label: 'Temporary',
|
||||
phase: 'on_event',
|
||||
kind: 'marker',
|
||||
scope: 'self',
|
||||
basis: 'flat',
|
||||
variants: { base: { value, expression } }
|
||||
}));
|
||||
}
|
||||
if (model === 'reducer') {
|
||||
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
||||
}
|
||||
@@ -633,7 +692,7 @@
|
||||
<div class="outcome-card">
|
||||
<div class="outcome-grid">
|
||||
<label class="field">LABEL<input value={outcome.label} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
|
||||
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{kind:values[0] as ConditionalMechanicOutcomeDraft['kind'],resource:values[0] === 'restoration' || values[0] === 'resource_cost' ? outcome.resource ?? 'health' : undefined,attributeId:values[0] === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined})}/>
|
||||
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{kind:values[0] as ConditionalMechanicOutcomeDraft['kind'],resource:values[0] === 'restoration' || values[0] === 'resource_cost' ? outcome.resource ?? 'health' : undefined,attributeId:values[0] === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined,delivery:values[0] === 'restoration' ? outcome.delivery : undefined,durationSeconds:values[0] === 'restoration' ? outcome.durationSeconds : undefined})}/>
|
||||
<label class="field">DESCRIPTION SLOT<input type="number" min="1" step="1" value={outcome.descriptionSlot ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{descriptionSlot:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
|
||||
</div>
|
||||
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{scope:values[0] as MechanicOutcomeScope})}/>
|
||||
@@ -642,6 +701,23 @@
|
||||
{:else if outcome.kind === 'restoration' || outcome.kind === 'resource_cost'}
|
||||
<SegmentedChoice legend="RESOURCE" name={`${idPrefix}-resource-${clauseIndex}-${outcomeIndex}`} options={[{value:'health',label:'HEALTH'},{value:'armor',label:'ARMOR'},{value:'ammo',label:'AMMO'}]} value={outcome.resource ?? 'health'} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{resource:values[0] as 'health'|'armor'|'ammo'})}/>
|
||||
{/if}
|
||||
{#if outcome.kind === 'restoration'}
|
||||
<div class="delivery-controls">
|
||||
<SegmentedChoice
|
||||
legend="DELIVERY"
|
||||
name={`${idPrefix}-delivery-${clauseIndex}-${outcomeIndex}`}
|
||||
options={[{value:'instant',label:'INSTANT'},{value:'continuous',label:'CONTINUOUS'}]}
|
||||
value={outcome.delivery ?? 'instant'}
|
||||
onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{
|
||||
delivery: values[0] === 'continuous' ? 'continuous' : undefined,
|
||||
durationSeconds: values[0] === 'continuous' ? outcome.durationSeconds ?? 3 : undefined
|
||||
})}
|
||||
/>
|
||||
{#if outcome.delivery === 'continuous'}
|
||||
<label class="field">DURATION (SECONDS)<input type="number" min="0.01" step="any" value={outcome.durationSeconds ?? 3} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{durationSeconds:Number(event.currentTarget.value)})}/></label>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if outcome.kind === 'direct_damage' || outcome.kind === 'restoration' || outcome.kind === 'resource_cost'}
|
||||
<SegmentedChoice legend="VALUE BASIS" name={`${idPrefix}-basis-${clauseIndex}-${outcomeIndex}`} options={[{value:'flat',label:'FLAT'},{value:'max_health_percent',label:'MAX HEALTH %'},{value:'current_health_percent',label:'CURRENT HEALTH %'},{value:'weapon_base_percent',label:'WEAPON BASE %'}]} value={outcome.basis} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{basis:values[0] as OutcomeBasis})}/>
|
||||
{/if}
|
||||
@@ -671,6 +747,15 @@
|
||||
columns={Math.min(4,rule.scenarioInputs.length)}
|
||||
onchange={(values) => setOutcomeValueModel(clauseIndex,outcomeIndex,'scenario',values[0])}
|
||||
/>
|
||||
{:else if outcomeValueModel(outcome) === 'attribute'}
|
||||
<SearchableAssignment
|
||||
items={attributes}
|
||||
mode="single"
|
||||
compact
|
||||
subjectLabel="VALUE ATTRIBUTE"
|
||||
value={outcomeAttributeReference(outcome)}
|
||||
onvaluechange={(value) => setOutcomeValueModel(clauseIndex,outcomeIndex,'attribute',value)}
|
||||
/>
|
||||
{/if}
|
||||
{#if rule.pools.length}
|
||||
<div class="state-scaling">
|
||||
@@ -743,13 +828,14 @@
|
||||
.outcome-grid{display:grid;grid-template-columns:minmax(200px,1fr) minmax(360px,1.4fr) minmax(120px,.3fr);align-items:end;gap:8px}
|
||||
.variant-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px}
|
||||
.state-scaling{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr) repeat(2,minmax(110px,.3fr));align-items:end;gap:8px}
|
||||
.delivery-controls{display:grid;grid-template-columns:minmax(260px,1fr) minmax(150px,.35fr);align-items:end;gap:8px}.delivery-controls>.field{margin:0}
|
||||
.outcome-footer{display:flex;align-items:end;justify-content:space-between;gap:8px}
|
||||
.remove,.danger-quiet{min-height:38px;padding:0 10px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}
|
||||
.remove:hover,.remove:focus-visible,.danger-quiet:hover,.danger-quiet:focus-visible{background:var(--shd-danger);color:var(--shd-surface)}
|
||||
.compact-action{min-height:36px}.add-branch{min-height:44px}.empty-state{padding:10px}.script-preview{display:grid;grid-template-columns:minmax(190px,.28fr) minmax(0,1fr);gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);border-top:2px solid var(--shd-success);background:var(--shd-surface)}
|
||||
.script-preview>div{display:grid;align-content:start;gap:3px}.script-preview>div span{color:var(--shd-success);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
|
||||
@media(max-width:1300px){.scenario-row{grid-template-columns:repeat(2,minmax(0,1fr))}.scenario-row>.description-field{grid-column:1/-1}}
|
||||
@media(max-width:1000px){.recipe-grid,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.script-preview{grid-template-columns:1fr 1fr}.recipe-grid>:first-child,.script-preview>:last-child{grid-column:1/-1}.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:1/-1}}
|
||||
@media(max-width:620px){.mechanic-heading,.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading,.replace-confirm,.outcome-footer{align-items:stretch;flex-direction:column}.recipe-grid,.scenario-row,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.script-preview{grid-template-columns:1fr}.recipe-grid>:first-child,.script-preview>:last-child,.scenario-row>.description-field,.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:auto}.branch>header>div{align-items:stretch;flex-direction:column}.branch>header .field{min-width:0}.remove,.danger-quiet,.compact-action,.add-branch{width:100%;min-height:44px}}
|
||||
@media(max-width:1000px){.recipe-grid,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.delivery-controls,.script-preview{grid-template-columns:1fr 1fr}.recipe-grid>:first-child,.script-preview>:last-child{grid-column:1/-1}.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:1/-1}}
|
||||
@media(max-width:620px){.mechanic-heading,.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading,.replace-confirm,.outcome-footer{align-items:stretch;flex-direction:column}.recipe-grid,.scenario-row,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.delivery-controls,.script-preview{grid-template-columns:1fr}.recipe-grid>:first-child,.script-preview>:last-child,.scenario-row>.description-field,.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:auto}.branch>header>div{align-items:stretch;flex-direction:column}.branch>header .field{min-width:0}.remove,.danger-quiet,.compact-action,.add-branch{width:100%;min-height:44px}}
|
||||
@media(prefers-reduced-motion:reduce){.remove,.danger-quiet{transition:none}}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user