block requirements
This commit is contained in:
+225
-41
@@ -12,6 +12,9 @@
|
||||
const blockGuideMaterials = new Map();
|
||||
let blockRequirementAction, blockRequirementsProjectProperty, blockGuidePreviewListener;
|
||||
let blockRequirementUndoSaveListener, blockRequirementUndoLoadListener;
|
||||
let blockRequirementPanel, blockRequirementPanelListener, blockRequirementPanelCss;
|
||||
let blockRequirementPendingTimer;
|
||||
let blockGuideProjectListener;
|
||||
const SYSTEM_TAG_OPTIONS = {
|
||||
'': 'None (normal animation)',
|
||||
manualpeak: 'Manual peak',
|
||||
@@ -738,6 +741,56 @@
|
||||
cube.mesh.material = material;
|
||||
}
|
||||
|
||||
function hideInternalBlockGuideFaces(cubes) {
|
||||
const faces = [['west', 0, 0], ['east', 0, 1], ['down', 1, 0], ['up', 1, 1], ['north', 2, 0], ['south', 2, 1]];
|
||||
const planes = new Map();
|
||||
const descriptors = [];
|
||||
const epsilon = 0.000001;
|
||||
for (const cube of cubes) {
|
||||
const kind = cube.name.includes('_clearance_') ? 'clearance' : 'required';
|
||||
for (const [name, axis, positive] of faces) {
|
||||
if (!cube.faces?.[name]) continue;
|
||||
const axes = [0, 1, 2].filter((value) => value !== axis);
|
||||
const rect = [cube.from[axes[0]], cube.from[axes[1]], cube.to[axes[0]], cube.to[axes[1]]];
|
||||
const key = `${kind}:${axis}:${(positive ? cube.to[axis] : cube.from[axis]).toFixed(6)}`;
|
||||
const descriptor = { cube, name, positive, rect, key };
|
||||
if (!planes.has(key)) planes.set(key, [[], []]);
|
||||
planes.get(key)[positive].push(descriptor);
|
||||
descriptors.push(descriptor);
|
||||
}
|
||||
}
|
||||
for (const face of descriptors) {
|
||||
let uncovered = [face.rect];
|
||||
// Subtract neighboring rectangles; several smaller faces can cover one large face.
|
||||
for (const neighbor of planes.get(face.key)[1 - face.positive]) {
|
||||
if (neighbor.cube === face.cube) continue;
|
||||
const next = [];
|
||||
for (const rect of uncovered) {
|
||||
const left = Math.max(rect[0], neighbor.rect[0]), bottom = Math.max(rect[1], neighbor.rect[1]);
|
||||
const right = Math.min(rect[2], neighbor.rect[2]), top = Math.min(rect[3], neighbor.rect[3]);
|
||||
if (right - left <= epsilon || top - bottom <= epsilon) { next.push(rect); continue; }
|
||||
if (left - rect[0] > epsilon) next.push([rect[0], rect[1], left, rect[3]]);
|
||||
if (rect[2] - right > epsilon) next.push([right, rect[1], rect[2], rect[3]]);
|
||||
if (bottom - rect[1] > epsilon) next.push([left, rect[1], right, bottom]);
|
||||
if (rect[3] - top > epsilon) next.push([left, top, right, rect[3]]);
|
||||
}
|
||||
uncovered = next;
|
||||
if (!uncovered.length) break;
|
||||
}
|
||||
// Null faces are omitted from Blockbench's mesh index, not just made transparent.
|
||||
face.cube.faces[face.name].texture = uncovered.length ? false : null;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshBlockGuideFaces() {
|
||||
const roots = Group.all.filter((group) => group.name.startsWith(BLOCK_GUIDE_PREFIX)
|
||||
&& !isBlockRequirementGuide(group.parent));
|
||||
for (const root of roots) {
|
||||
hideInternalBlockGuideFaces(Cube.all.filter((cube) => cube.getAllAncestors().includes(root)));
|
||||
}
|
||||
if (roots.length) Canvas.updateAllFaces();
|
||||
}
|
||||
|
||||
function rebuildBlockRequirementGuides(animationId, entry) {
|
||||
const boxes = entry ? buildBlockRequirementGuideBoxes(entry) : [];
|
||||
const groupName = `${BLOCK_GUIDE_PREFIX}${animationId}`;
|
||||
@@ -751,21 +804,28 @@
|
||||
const createdGroups = [];
|
||||
Undo.initEdit({ outliner: true, elements: oldElements, groups: oldGroups,
|
||||
block_requirement_edit: true });
|
||||
for (const group of oldGroups) group.remove();
|
||||
if (entry) {
|
||||
const root = new Group({ name: groupName, export: false });
|
||||
root.init();
|
||||
root.addTo('root');
|
||||
const root = oldGroups[0] || new Group({ name: groupName, export: false });
|
||||
if (!oldGroups.length) { root.init(); root.addTo('root'); }
|
||||
createdGroups.push(root);
|
||||
boxes.forEach((box, index) => {
|
||||
const cube = new Cube({ name: `${BLOCK_GUIDE_PREFIX}${box.kind}_${box.label}_${index + 1}`,
|
||||
from: box.from, to: box.to, export: false, box_uv: false });
|
||||
cube.init();
|
||||
cube.addTo(root);
|
||||
const data = { name: `${BLOCK_GUIDE_PREFIX}${box.kind}_${box.label}_${index + 1}`,
|
||||
from: box.from, to: box.to, export: false, box_uv: false };
|
||||
const cube = oldElements[index] || new Cube(data);
|
||||
if (oldElements[index]) {
|
||||
cube.name = data.name;
|
||||
cube.from = box.from.slice();
|
||||
cube.to = box.to.slice();
|
||||
cube.export = false;
|
||||
} else { cube.init(); cube.addTo(root); }
|
||||
createdElements.push(cube);
|
||||
});
|
||||
for (const cube of oldElements.slice(boxes.length)) cube.remove();
|
||||
} else {
|
||||
for (const group of oldGroups) group.remove();
|
||||
}
|
||||
Project[BLOCK_REQUIREMENTS_PROPERTY] = JSON.stringify(updated);
|
||||
hideInternalBlockGuideFaces(createdElements);
|
||||
Canvas.updateAll();
|
||||
for (const cube of createdElements) applyBlockRequirementGuideMaterial(cube);
|
||||
Undo.finishEdit(entry ? 'Set block requirement' : 'Remove block requirement',
|
||||
@@ -773,7 +833,7 @@
|
||||
Project.saved = false;
|
||||
}
|
||||
|
||||
function showBlockRequirementEditor(animationId) {
|
||||
function buildBlockRequirementForm(animationId) {
|
||||
const entry = getAuthoredBlockRequirement(animationId);
|
||||
const req = (entry ? entry.requirement : getSavedAnimdef(animationId).block_requirements) || {};
|
||||
const wall = (result) => result?.type === 'wall';
|
||||
@@ -822,40 +882,149 @@
|
||||
surface_height: { label: 'Custom surface height', type: 'number', value: entry?.preview?.surfaceHeight ?? 1, min: 0.0625, max: 2, step: 0.0625,
|
||||
condition: (result) => center(result) && result?.support === 'surface', description: 'For example a bed is 0.5625 blocks high; slab/half-height mode uses 0.5.' },
|
||||
};
|
||||
new Dialog({ id: 'animationdirector_block_requirement', title: 'Block Requirement', width: 680, form,
|
||||
buttons: ['Cancel', 'Remove Requirement', 'Save & Build Guides'], cancelIndex: 0, confirmIndex: 2,
|
||||
onButton(index) {
|
||||
if (index === 1) {
|
||||
rebuildBlockRequirementGuides(animationId, null);
|
||||
this.hide();
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
function cancelBlockRequirementUpdate() {
|
||||
if (blockRequirementPendingTimer !== undefined) clearTimeout(blockRequirementPendingTimer);
|
||||
blockRequirementPendingTimer = undefined;
|
||||
}
|
||||
|
||||
function blockRequirementAnimationIds() {
|
||||
let groups = [];
|
||||
try { groups = collectConjoinedAnimationGroups().groups; } catch (_) { /* Allow authoring before clips exist. */ }
|
||||
return Array.from(new Set([...groups.map((group) => group.id), ...Object.keys(getBlockRequirementEntries())])).sort();
|
||||
}
|
||||
|
||||
function createBlockRequirementPanel() {
|
||||
blockRequirementPanelCss = Blockbench.addCSS(`
|
||||
.ad-block-panel { padding: 10px; overflow-y: auto; height: 100%; box-sizing: border-box; }
|
||||
.ad-block-panel p { margin: 6px 0; font-size: 12px; color: var(--color-text); opacity: .8; }
|
||||
.ad-block-panel .ad-block-target { display: flex; gap: 6px; margin-bottom: 8px; }
|
||||
.ad-block-panel .ad-block-target input { width: 0; flex: 1; }
|
||||
.ad-block-panel select { width: 100%; min-width: 0; }
|
||||
.ad-block-panel details { margin: 10px 0; border: 1px solid var(--color-border); border-radius: 4px; padding: 8px; }
|
||||
.ad-block-panel summary { font-weight: bold; cursor: pointer; color: var(--color-accent); }
|
||||
.ad-block-panel .ad-block-field { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px; }
|
||||
.ad-block-panel .ad-block-field label { width: 100%; font-size: 12px; }
|
||||
.ad-block-panel .ad-block-field input:not([type=checkbox]) { width: 100%; box-sizing: border-box; }
|
||||
.ad-block-panel .ad-block-error { color: var(--color-error); opacity: 1; }
|
||||
.ad-block-panel .ad-block-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
`);
|
||||
blockRequirementPanel = new Panel('animationdirector_block_requirements', {
|
||||
name: 'Block Requirements', icon: 'view_in_ar', condition: () => !!Project,
|
||||
growable: true, resizable: true, min_height: 240, expand_button: true,
|
||||
default_position: { slot: 'right_bar', height: 520, float_size: [360, 620] },
|
||||
component: {
|
||||
data() { return { projectId: null, animationId: '', draftId: '', ids: [], form: {}, values: {}, error: '', status: '', dirty: false }; },
|
||||
computed: {
|
||||
sections() {
|
||||
const sections = [{ title: 'Requirement', fields: [] }];
|
||||
for (const [key, field] of Object.entries(this.form)) {
|
||||
if (key === '_about') continue;
|
||||
if (field.condition && !field.condition(this.values)) continue;
|
||||
if (field.type === 'info' && field.text.startsWith('### ')) {
|
||||
sections.push({ title: field.text.slice(4).split('\n')[0], fields: [], hint: field.text.split('\n').slice(1).join('\n') });
|
||||
} else sections[sections.length - 1].fields.push({ key, ...field });
|
||||
}
|
||||
return sections.filter((section) => section.fields.length);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
load(id) {
|
||||
cancelBlockRequirementUpdate();
|
||||
this.projectId = Project?.uuid || null;
|
||||
this.ids = Project ? blockRequirementAnimationIds() : [];
|
||||
this.animationId = id || '';
|
||||
this.draftId = this.animationId;
|
||||
this.form = Project && id ? buildBlockRequirementForm(id) : {};
|
||||
this.values = Object.fromEntries(Object.entries(this.form).filter(([, field]) => field.type !== 'info').map(([key, field]) => [key, field.value]));
|
||||
this.error = ''; this.dirty = false;
|
||||
this.status = id ? 'Changes update the guides and are saved in this project automatically.' : 'Select an animation or enter a new animation ID.';
|
||||
},
|
||||
openId() {
|
||||
const id = String(this.draftId).trim();
|
||||
if (!/^[a-z0-9._-]+$/.test(id)) { this.error = 'Enter a valid lowercase animation ID (without the stage suffix).'; return; }
|
||||
this.flush(); this.load(id);
|
||||
},
|
||||
selectId(event) { this.flush(); this.load(event.target.value); },
|
||||
input(key, field, event) {
|
||||
this.$set(this.values, key, field.type === 'checkbox' ? event.target.checked : event.target.value);
|
||||
this.dirty = true;
|
||||
cancelBlockRequirementUpdate();
|
||||
try { buildBlockRequirementGuideBoxes(readBlockRequirementForm(this.values)); this.error = ''; }
|
||||
catch (e) { this.error = `${e.message} The last valid preview is unchanged.`; return; }
|
||||
// Coalesce a typing/arrow-key burst without keeping a native Undo edit open.
|
||||
blockRequirementPendingTimer = setTimeout(() => this.flush(), 180);
|
||||
},
|
||||
flush(force = false) {
|
||||
cancelBlockRequirementUpdate();
|
||||
if (!this.animationId || !Project || this.projectId !== Project.uuid || (!this.dirty && !force)) return;
|
||||
try {
|
||||
const entry = readBlockRequirementForm(this.values);
|
||||
buildBlockRequirementGuideBoxes(entry);
|
||||
const hasGuides = Group.all.some((group) => group.name === `${BLOCK_GUIDE_PREFIX}${this.animationId}`);
|
||||
if (!hasGuides || JSON.stringify(entry) !== JSON.stringify(getAuthoredBlockRequirement(this.animationId))) {
|
||||
rebuildBlockRequirementGuides(this.animationId, entry);
|
||||
}
|
||||
this.error = ''; this.dirty = false;
|
||||
this.ids = blockRequirementAnimationIds();
|
||||
this.status = 'Preview updated. Blue: structure / floor. Red: clearance. Undo is available.';
|
||||
} catch (e) { this.error = `${e.message} The last valid preview is unchanged.`; }
|
||||
},
|
||||
remove() {
|
||||
cancelBlockRequirementUpdate();
|
||||
if (!this.animationId || !Project || this.projectId !== Project.uuid) return;
|
||||
rebuildBlockRequirementGuides(this.animationId, null);
|
||||
this.load(this.animationId);
|
||||
this.status = 'Requirement removed. Edit a value or click Build guides to add it again.';
|
||||
},
|
||||
},
|
||||
template: `<div class="ad-block-panel">
|
||||
<select :value="animationId" @change="selectId"><option value="">Select animation...</option><option v-for="id in ids" :key="id" :value="id">{{ id }}</option></select>
|
||||
<div class="ad-block-target"><input v-model="draftId" placeholder="New animation ID" @keydown.enter="openId"><button @click="openId">Open</button></div>
|
||||
<p>Live guides: 16 pixels = 1 block. Actors face -Z; wall clearance extends toward +Z. Guides are not exported.</p>
|
||||
<p v-if="error" class="ad-block-error">{{ error }}</p><p v-else>{{ status }}</p>
|
||||
<details v-for="section in sections" :key="section.title" open>
|
||||
<summary>{{ section.title }}</summary><p v-if="section.hint">{{ section.hint }}</p>
|
||||
<div v-for="field in section.fields" :key="field.key" class="ad-block-field">
|
||||
<p v-if="field.type === 'info'">{{ field.text }}</p>
|
||||
<template v-else><label :for="'ad-block-' + field.key">{{ field.label }}</label>
|
||||
<select v-if="field.type === 'select'" :id="'ad-block-' + field.key" :value="values[field.key]" @change="input(field.key, field, $event); flush()">
|
||||
<option v-for="(label, value) in field.options" :key="value" :value="value">{{ label }}</option>
|
||||
</select>
|
||||
<input v-else :id="'ad-block-' + field.key" :type="field.type === 'checkbox' ? 'checkbox' : field.type === 'number' ? 'number' : 'text'"
|
||||
:value="values[field.key]" :checked="!!values[field.key]" :min="field.min" :max="field.max" :step="field.step" :placeholder="field.placeholder"
|
||||
@input="input(field.key, field, $event)" @blur="flush()">
|
||||
<p v-if="field.description">{{ field.description }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</details>
|
||||
<div v-if="animationId" class="ad-block-actions"><button @click="flush(true)">Build guides</button><button @click="remove">Remove requirement</button></div>
|
||||
</div>`,
|
||||
},
|
||||
onConfirm(result) {
|
||||
try { rebuildBlockRequirementGuides(animationId, readBlockRequirementForm(result)); return true; }
|
||||
catch (e) { Blockbench.showMessageBox({ title: 'Block Requirement', message: e.message, buttons: ['OK'] }); return false; }
|
||||
},
|
||||
}).show();
|
||||
});
|
||||
blockRequirementPanelListener = () => {
|
||||
cancelBlockRequirementUpdate();
|
||||
const view = blockRequirementPanel?.vue;
|
||||
if (view) view.load(Project && view.projectId === Project.uuid ? view.animationId : '');
|
||||
};
|
||||
Blockbench.on('select_project unselect_project undo redo', blockRequirementPanelListener);
|
||||
}
|
||||
|
||||
function addBlockRequirement() {
|
||||
if (!Project) return Blockbench.showQuickMessage('Open a project first.', 2500);
|
||||
let groups;
|
||||
try { groups = collectConjoinedAnimationGroups().groups; }
|
||||
catch (e) { return Blockbench.showMessageBox({ title: 'Block Requirement', message: e.message, buttons: ['OK'] }); }
|
||||
const options = Object.fromEntries(groups.map((group) => [group.id, group.id]));
|
||||
if (!blockRequirementPanel) createBlockRequirementPanel();
|
||||
const view = blockRequirementPanel.vue;
|
||||
view.flush();
|
||||
const selected = parseAfwStageAnimationName(getSelectedAnimation());
|
||||
const defaultId = selected?.id || groups[0]?.id || '';
|
||||
new Dialog({ id: 'animationdirector_block_requirement_target', title: 'Add / Edit Block Requirement', width: 480,
|
||||
form: { animation: groups.length
|
||||
? { label: 'Animation', type: 'select', options, value: options[defaultId] ? defaultId : groups[0].id }
|
||||
: { label: 'Animation ID', type: 'text', value: '', placeholder: 'wolfmplayer', description: 'Use the ID of the future <animationId>.p1 stage, without .p1.' } },
|
||||
onConfirm(result) {
|
||||
const id = String(result.animation || '').trim();
|
||||
if (!/^[a-z0-9._-]+$/.test(id)) { Blockbench.showQuickMessage('Enter a valid lowercase animation ID.', 2500); return false; }
|
||||
showBlockRequirementEditor(id);
|
||||
return true;
|
||||
},
|
||||
}).show();
|
||||
const ids = blockRequirementAnimationIds();
|
||||
view.load(selected?.id || (view.projectId === Project.uuid && view.animationId) || ids[0] || '');
|
||||
if (blockRequirementPanel.slot === 'hidden') blockRequirementPanel.moveTo('right_bar');
|
||||
blockRequirementPanel.folded = false;
|
||||
blockRequirementPanel.getContainerPanel().selectTab(blockRequirementPanel);
|
||||
blockRequirementPanel.update();
|
||||
if (blockRequirementPanel.slot === 'float') blockRequirementPanel.moveToFront();
|
||||
}
|
||||
|
||||
function stripBlockRequirementGuideBones(clip) {
|
||||
@@ -2740,8 +2909,8 @@
|
||||
form._block_requirement = {
|
||||
type: 'info',
|
||||
text: blockRequirement
|
||||
? `#### Block requirement\nIncluded automatically:\n\n\`\`\`json\n${JSON.stringify(blockRequirement, null, 2)}\n\`\`\`\nEdit using Tools > AnimationDirector Anims > Add Block Requirement.`
|
||||
: '#### Block requirement\nNone. Use Tools > AnimationDirector Anims > Add Block Requirement to create one.',
|
||||
? `#### Block requirement\nIncluded automatically:\n\n\`\`\`json\n${JSON.stringify(blockRequirement, null, 2)}\n\`\`\`\nEdit using Tools > AnimationDirector Anims > Block Requirements.`
|
||||
: '#### Block requirement\nNone. Use Tools > AnimationDirector Anims > Block Requirements to create one.',
|
||||
};
|
||||
form.base_speed = {
|
||||
label: 'Base playback speed', type: 'text', value: saved.speed ?? '', placeholder: 'Default (1.0)',
|
||||
@@ -4033,6 +4202,9 @@ You can import or export complete AFW animation IDs as one file containing all s
|
||||
blockGuidePreviewListener = ({ element }) => applyBlockRequirementGuideMaterial(element);
|
||||
Cube.preview_controller.on('setup update_faces', blockGuidePreviewListener);
|
||||
for (const cube of Cube.all) applyBlockRequirementGuideMaterial(cube);
|
||||
blockGuideProjectListener = refreshBlockGuideFaces;
|
||||
Blockbench.on('select_project', blockGuideProjectListener);
|
||||
refreshBlockGuideFaces();
|
||||
blockRequirementUndoSaveListener = ({ save, aspects }) => {
|
||||
if (aspects.block_requirement_edit) save[BLOCK_REQUIREMENTS_PROPERTY] = Project[BLOCK_REQUIREMENTS_PROPERTY] || '{}';
|
||||
};
|
||||
@@ -4043,8 +4215,8 @@ You can import or export complete AFW animation IDs as one file containing all s
|
||||
Blockbench.on('load_undo_save', blockRequirementUndoLoadListener);
|
||||
|
||||
blockRequirementAction = new Action('animationdirector_add_block_requirement', {
|
||||
name: 'Add Block Requirement',
|
||||
description: 'Configure a NoN block requirement and create blue structure / red clearance guides.',
|
||||
name: 'Block Requirements',
|
||||
description: 'Open the live block requirement panel with blue structure / red clearance guides.',
|
||||
icon: 'view_in_ar',
|
||||
click: addBlockRequirement,
|
||||
});
|
||||
@@ -4188,6 +4360,18 @@ You can import or export complete AFW animation IDs as one file containing all s
|
||||
exportConjoinedAction.delete();
|
||||
createAnimationDefinitionAction.delete();
|
||||
blockRequirementAction.delete();
|
||||
Blockbench.removeListener('select_project', blockGuideProjectListener);
|
||||
cancelBlockRequirementUpdate();
|
||||
if (blockRequirementPanelListener) {
|
||||
for (const event of ['select_project', 'unselect_project', 'undo', 'redo']) {
|
||||
Blockbench.removeListener(event, blockRequirementPanelListener);
|
||||
}
|
||||
}
|
||||
blockRequirementPanel?.delete();
|
||||
blockRequirementPanelCss?.delete();
|
||||
blockRequirementPanel = undefined;
|
||||
blockRequirementPanelListener = undefined;
|
||||
blockRequirementPanelCss = undefined;
|
||||
Cube.preview_controller.removeListener('setup', blockGuidePreviewListener);
|
||||
Cube.preview_controller.removeListener('update_faces', blockGuidePreviewListener);
|
||||
Blockbench.removeListener('create_undo_save', blockRequirementUndoSaveListener);
|
||||
|
||||
Reference in New Issue
Block a user